Skip to main content

🛠️ Weekend Project: Backend Fundamentals

Time to prove the fundamentals stuck. Over this weekend you'll build the same Task Manager REST API three times — in Node.js, Python, and PHP — and watch how the shared concepts you've learned survive three completely different languages. It's the most convincing "aha" this module has to offer.

🎯 Learning Objectives

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

  • Build a full CRUD REST API with correct routes, status codes, and validation from scratch
  • Implement the same specification in Node.js/Express, Python/Flask, and plain PHP
  • Compare how each stack handles routing, request parsing, and error responses
  • Test an API end-to-end with curl (or Postman) and verify each endpoint
  • Judge your own work against a "what good looks like" rubric

Estimated Time: A weekend (4–8 focused hours)  •  Difficulty: Intermediate

Hands-on: This entire lesson is the exercise — build all three servers, tick the checklist, and self-assess.

In This Lesson

The Mission

Reading about backends teaches you the vocabulary. Building one teaches you the reflexes. This weekend project is a deliberate rep: you'll construct a small but complete API — a Task Manager supporting create, read, update, and delete — and then rebuild it, feature for feature, in two more languages.

Why three times? Because the third build is where it clicks. The first server feels like learning; the second feels like translation; by the third you're just filling in the same boxes with different syntax. That transfer — recognizing the shape of a backend beneath any language — is the real deliverable of Module 15.

📖 What you'll have by Sunday night

Three working servers, all answering the same five HTTP requests, each running on its own port. You'll be able to point one small HTML frontend at any of them and it will just work — that's the promise of a well-designed API contract.

💡 Treat this like a real ticket. Don't copy-paste blindly. Type the code, break it on purpose, read the error, fix it. The debugging is the class.

Before you start — prerequisites

  • Node.js 18+ installed (node --version)
  • Python 3.10+ installed (python --version)
  • PHP 8.1+ installed (php --version)
  • A terminal and a way to send HTTP requests — curl is built in on macOS/Linux and modern Windows; Postman is a friendly GUI alternative
  • Comfort with basic HTTP verbs and JSON (covered earlier in this module)

The Spec You're Building

A good backend starts from a clear contract. Here's the exact API all three servers must implement. Build to this spec and your frontend never has to care which language answered.

Endpoints

Method Path Purpose Success status
GET/api/tasksList all tasks200 OK
GET/api/tasks/:idFetch one task200 OK
POST/api/tasksCreate a task201 Created
PUT/api/tasks/:idUpdate a task200 OK
DELETE/api/tasks/:idRemove a task200 OK

The Task object

{
  "id": "string",           // server-generated unique id
  "title": "string",        // required, non-empty
  "description": "string",  // optional, defaults to ""
  "status": "string",       // "pending" | "in-progress" | "completed"
  "created_at": "string"    // ISO 8601 timestamp
}

The rules every server must follow

⚠️ Non-negotiable behaviours

  • Missing title on create → 400 Bad Request with { "error": "Title is required" }
  • Unknown id on GET/PUT/DELETE → 404 Not Found
  • Invalid status on update → 400 Bad Request
  • New tasks always start as "pending"
  • All responses are Content-Type: application/json

The whole request lifecycle you'll build looks like this — identical across all three languages:

The request lifecycle of the Task Manager API A client request enters routing, is parsed and validated, reads or writes the in-memory store, then a JSON response with a status code is returned. Client curl / browser Router method + path Validate parse body Data store read / write JSON response + status code
Figure 1 — Route → validate → touch the data store → return JSON. Every endpoint you write is a variation on this one path.

Milestones & Timeline

Ship it in stages. Each milestone is independently runnable and testable, so you always have something working to fall back on. Don't move on until the current server passes all five requests.

flowchart LR M1["Milestone 1
Node.js / Express"] --> M2["Milestone 2
Python / Flask"] M2 --> M3["Milestone 3
Plain PHP"] M3 --> M4["Milestone 4
Test & Compare"] M4 --> S["Stretch goals
(optional)"]
MilestoneSuggested timeDone when…
1 — Node/Express60–90 minAll 5 endpoints work via curl
2 — Python/Flask45–60 minSame 5 endpoints, port 5000
3 — Plain PHP60–90 minSame 5 endpoints, port 8000
4 — Test & compare30–45 minComparison notes written

Recommended project layout so the three servers never collide:

weekend-project/
├── nodejs-server/
│   ├── package.json
│   └── index.js
├── python-server/
│   ├── requirements.txt
│   └── app.py
└── php-server/
    └── index.php

Milestone 1 — Node.js / Express

Express is the most popular Node.js web framework. Its routing reads almost like the spec table above, which makes it a great first build.

Setup

mkdir -p weekend-project/nodejs-server
cd weekend-project/nodejs-server
npm init -y
npm install express

💡 Modern touch

Node 18+ ships a global crypto.randomUUID(), so you no longer need the uuid package just to generate ids. Fewer dependencies, less to maintain.

index.js

const express = require('express');
const { randomUUID } = require('crypto');

const app = express();
const PORT = process.env.PORT || 3000;

app.use(express.json()); // parse JSON request bodies

// In-memory store (resets when the server restarts)
let tasks = [];
const VALID_STATUS = ['pending', 'in-progress', 'completed'];
const findTask = (id) => tasks.find((t) => t.id === id);

// GET all
app.get('/api/tasks', (req, res) => {
  res.json(tasks);
});

// GET one
app.get('/api/tasks/:id', (req, res) => {
  const task = findTask(req.params.id);
  if (!task) return res.status(404).json({ error: 'Task not found' });
  res.json(task);
});

// CREATE
app.post('/api/tasks', (req, res) => {
  const { title, description } = req.body ?? {};
  if (!title) return res.status(400).json({ error: 'Title is required' });

  const task = {
    id: randomUUID(),
    title,
    description: description ?? '',
    status: 'pending',
    created_at: new Date().toISOString(),
  };
  tasks.push(task);
  res.status(201).json(task);
});

// UPDATE
app.put('/api/tasks/:id', (req, res) => {
  const task = findTask(req.params.id);
  if (!task) return res.status(404).json({ error: 'Task not found' });

  const { title, description, status } = req.body ?? {};
  if (status && !VALID_STATUS.includes(status)) {
    return res.status(400).json({ error: 'Invalid status value' });
  }
  if (title !== undefined) task.title = title;
  if (description !== undefined) task.description = description;
  if (status !== undefined) task.status = status;
  res.json(task);
});

// DELETE
app.delete('/api/tasks/:id', (req, res) => {
  const index = tasks.findIndex((t) => t.id === req.params.id);
  if (index === -1) return res.status(404).json({ error: 'Task not found' });
  tasks.splice(index, 1);
  res.json({ message: 'Task deleted successfully' });
});

app.listen(PORT, () => {
  console.log(`Node server running at http://localhost:${PORT}`);
});

Run it

node index.js
# → Node server running at http://localhost:3000

✅ Milestone 1 checkpoint

Create a task with a POST, then list it with a GET. If the new task comes back with an id, a created_at, and status: "pending", milestone 1 is done.

Milestone 2 — Python / Flask

Now translate. Notice how Flask's @app.route decorators map to the same five endpoints — the structure is identical, only the syntax changes.

Setup

mkdir -p weekend-project/python-server
cd weekend-project/python-server
python -m venv venv

# Activate — macOS/Linux:
source venv/bin/activate
# Windows (PowerShell):
# venv\Scripts\Activate.ps1

pip install flask
pip freeze > requirements.txt

app.py

from flask import Flask, request, jsonify
from datetime import datetime, timezone
import uuid

app = Flask(__name__)

tasks = []  # in-memory store
VALID_STATUS = {'pending', 'in-progress', 'completed'}


def find_task(task_id):
    return next((t for t in tasks if t['id'] == task_id), None)


@app.get('/api/tasks')
def get_all_tasks():
    return jsonify(tasks)


@app.get('/api/tasks/<task_id>')
def get_task(task_id):
    task = find_task(task_id)
    if task is None:
        return jsonify(error='Task not found'), 404
    return jsonify(task)


@app.post('/api/tasks')
def create_task():
    data = request.get_json(silent=True) or {}
    if not data.get('title'):
        return jsonify(error='Title is required'), 400

    task = {
        'id': str(uuid.uuid4()),
        'title': data['title'],
        'description': data.get('description', ''),
        'status': 'pending',
        'created_at': datetime.now(timezone.utc).isoformat(),
    }
    tasks.append(task)
    return jsonify(task), 201


@app.put('/api/tasks/<task_id>')
def update_task(task_id):
    task = find_task(task_id)
    if task is None:
        return jsonify(error='Task not found'), 404

    data = request.get_json(silent=True) or {}
    if 'status' in data and data['status'] not in VALID_STATUS:
        return jsonify(error='Invalid status value'), 400

    for field in ('title', 'description', 'status'):
        if field in data:
            task[field] = data[field]
    return jsonify(task)


@app.delete('/api/tasks/<task_id>')
def delete_task(task_id):
    global tasks
    if find_task(task_id) is None:
        return jsonify(error='Task not found'), 404
    tasks = [t for t in tasks if t['id'] != task_id]
    return jsonify(message='Task deleted successfully')


if __name__ == '__main__':
    app.run(debug=True, port=5000)

Run it

python app.py
# → Running on http://127.0.0.1:5000

💡 Notice the parallels

Flask's method-specific decorators (@app.get, @app.post) are the direct cousins of Express's app.get() and app.post(). The validation logic, the 404s, the 201 on create — line for line, the same decisions.

Milestone 3 — Plain PHP

The third build is the most revealing because PHP has no framework here — you write the router yourself. Doing routing by hand once makes you appreciate what Express and Flask do for free.

Setup

mkdir -p weekend-project/php-server
cd weekend-project/php-server
touch index.php

💡 One file, in-memory, per request

To keep this milestone parallel with the other two, we use a static in-memory array. Note a PHP quirk: each HTTP request is a fresh script run, so a purely in-memory store won't persist between requests. A stretch goal below shows how to persist to a JSON file — but first, get the routing and shapes right.

index.php

<?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');

if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
    http_response_code(204);
    exit;
}

// --- Persistence (JSON file so data survives between requests) ---
const STORE = __DIR__ . '/tasks.json';
function load_tasks(): array {
    return file_exists(STORE)
        ? (json_decode(file_get_contents(STORE), true) ?: [])
        : [];
}
function save_tasks(array $tasks): void {
    file_put_contents(STORE, json_encode($tasks, JSON_PRETTY_PRINT));
}
function send($data, int $status = 200): void {
    http_response_code($status);
    echo json_encode($data);
    exit;
}

$valid = ['pending', 'in-progress', 'completed'];

// --- Parse method + path ---
$method = $_SERVER['REQUEST_METHOD'];
$path   = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$parts  = array_values(array_filter(explode('/', $path)));

// Expect /api/tasks or /api/tasks/{id}
if (($parts[0] ?? '') !== 'api' || ($parts[1] ?? '') !== 'tasks') {
    send(['error' => 'Not Found'], 404);
}
$id = $parts[2] ?? null;

$tasks = load_tasks();
$body  = json_decode(file_get_contents('php://input'), true) ?? [];

switch ($method) {
    case 'GET':
        if ($id === null) send($tasks);
        foreach ($tasks as $t) if ($t['id'] === $id) send($t);
        send(['error' => 'Task not found'], 404);

    case 'POST':
        if (empty($body['title'])) send(['error' => 'Title is required'], 400);
        $task = [
            'id'          => bin2hex(random_bytes(8)),
            'title'       => $body['title'],
            'description' => $body['description'] ?? '',
            'status'      => 'pending',
            'created_at'  => date('c'),
        ];
        $tasks[] = $task;
        save_tasks($tasks);
        send($task, 201);

    case 'PUT':
        if ($id === null) send(['error' => 'Task ID is required'], 400);
        if (isset($body['status']) && !in_array($body['status'], $valid, true)) {
            send(['error' => 'Invalid status value'], 400);
        }
        foreach ($tasks as $i => $t) {
            if ($t['id'] === $id) {
                foreach (['title', 'description', 'status'] as $f) {
                    if (array_key_exists($f, $body)) $tasks[$i][$f] = $body[$f];
                }
                save_tasks($tasks);
                send($tasks[$i]);
            }
        }
        send(['error' => 'Task not found'], 404);

    case 'DELETE':
        if ($id === null) send(['error' => 'Task ID is required'], 400);
        foreach ($tasks as $i => $t) {
            if ($t['id'] === $id) {
                array_splice($tasks, $i, 1);
                save_tasks($tasks);
                send(['message' => 'Task deleted successfully']);
            }
        }
        send(['error' => 'Task not found'], 404);

    default:
        send(['error' => 'Method not allowed'], 405);
}

Run it

php -S localhost:8000
# → PHP Development Server started on http://localhost:8000

✅ Milestone 3 checkpoint

Because PHP persists to tasks.json, tasks you create now survive a server restart — a small but real difference from the other two builds. Note it for your comparison table.

Milestone 4 — Test & Compare

Now hammer each server with the same requests. These curl commands work against all three — just swap the port (Node 3000, Flask 5000, PHP 8000).

The five-request smoke test

# 1. Create a task
curl -X POST http://localhost:3000/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title":"Finish weekend project","description":"All three servers"}'

# 2. List all tasks
curl http://localhost:3000/api/tasks

# 3. Get one (paste the id from step 1)
curl http://localhost:3000/api/tasks/PASTE_ID_HERE

# 4. Update its status
curl -X PUT http://localhost:3000/api/tasks/PASTE_ID_HERE \
  -H "Content-Type: application/json" \
  -d '{"status":"in-progress"}'

# 5. Delete it
curl -X DELETE http://localhost:3000/api/tasks/PASTE_ID_HERE

Also test the failure paths

# Missing title → 400
curl -i -X POST http://localhost:3000/api/tasks \
  -H "Content-Type: application/json" -d '{}'

# Unknown id → 404
curl -i http://localhost:3000/api/tasks/does-not-exist

# Bad status → 400
curl -i -X PUT http://localhost:3000/api/tasks/PASTE_ID_HERE \
  -H "Content-Type: application/json" -d '{"status":"banana"}'

Expected on a successful create (201):

{
  "id": "3f2a…",
  "title": "Finish weekend project",
  "description": "All three servers",
  "status": "pending",
  "created_at": "2026-07-31T18:04:22.000Z"
}

Fill in this comparison table as you go

ConcernNode / ExpressPython / FlaskPlain PHP
Routingapp.get(path, fn)@app.get(path) decoratorHand-written switch
Parse JSON bodyexpress.json()req.bodyrequest.get_json()json_decode(php://input)
Path paramreq.params.idfunction argumentmanual URL split
Set status coderes.status(201)return data, 201http_response_code(201)
Data persistencein-memory (lost on restart)in-memory (lost on restart)JSON file (persists)

What Good Looks Like

Anyone can get a happy-path GET to return something. The difference between "it runs" and "it's good" lives in the edges. Hold your three servers to this rubric.

✅ Signs of a solid build

  • Every endpoint returns the correct status code, not just 200 for everything.
  • Validation runs before touching the data store — no half-created tasks.
  • Error responses are JSON with a consistent shape ({ "error": "…" }), never a raw HTML crash page.
  • The three servers are behaviourally interchangeable: the same curl command gives the same shape from all three.
  • Code is readable — helper functions for lookups, no copy-pasted validation.

⚠️ Common ways it goes wrong

  • Returning 200 when a task wasn't found (should be 404).
  • Forgetting express.json() / get_json() so req.body is undefined.
  • Creating a task with an empty title because validation checked "key exists" instead of "value is non-empty".
  • Hard-coding an id instead of generating a unique one.
  • In PHP, expecting an in-memory array to persist between requests — it won't without a file or database.

📖 The mindset that separates the two

Good backend developers think in terms of the contract, not the code. Before writing a handler they ask: "What does the caller get when this succeeds, and what do they get for every way it can fail?" Answer that for all five endpoints and your API is genuinely good.

Completion Checklist

Tick these off before you call the weekend a win. If any box won't check, that's your next debugging session.

🧾 Definition of done

  • ☐ Node server runs on :3000 and passes all five requests
  • ☐ Flask server runs on :5000 and passes all five requests
  • ☐ PHP server runs on :8000 and passes all five requests
  • POST with no title returns 400 on all three
  • GET /api/tasks/unknown-id returns 404 on all three
  • PUT with an invalid status returns 400 on all three
  • ☐ A created task has a generated id, an ISO created_at, and status: "pending"
  • ☐ Every response is valid JSON with Content-Type: application/json
  • ☐ Comparison table filled in with your own observations
  • ☐ (Bonus) One HTML page can talk to any of the three by changing only the port

Stretch Goals

Finished early, or want to push further? Pick one — each maps to a real skill you'll use on the job.

1. A shared frontend

Build one small HTML page that lists tasks, adds them, and deletes them. Point API_BASE at any of your three servers and confirm it works unchanged — the clearest possible proof that a clean API contract decouples frontend from backend.

const API_BASE = 'http://localhost:3000/api/tasks'; // swap the port

async function loadTasks() {
  const res = await fetch(API_BASE);
  const tasks = await res.json();
  console.table(tasks);
}

async function addTask(title, description = '') {
  const res = await fetch(API_BASE, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ title, description }),
  });
  return res.json();
}

loadTasks();

2. Persist Node & Flask too

Swap the in-memory arrays for a JSON file (like PHP), or go further and wire up SQLite. Now data survives restarts everywhere.

3. Add proper error handling middleware

In Express, add a final app.use((err, req, res, next) => …) handler. In Flask, register an @app.errorhandler. Return consistent JSON for unexpected errors instead of stack traces.

💡 If you only do one

Do the shared frontend. Watching the same UI drive three different backends without a single change is the moment the "different tools, same fundamentals" idea stops being a slogan and becomes something you've felt.

Summary & Quiz

🎉 Key Takeaways

  • A REST API is a contract first — routes, status codes, and validation — and code second.
  • The same CRUD API in Node, Flask, and PHP proves the concepts transfer across stacks; only syntax changes.
  • Frameworks (Express, Flask) hand you routing and body-parsing; writing PHP's router by hand shows exactly what they save you.
  • "Good" is defined by the failure paths: correct 400s and 404s, consistent JSON errors.
  • A clean API contract lets one frontend talk to any backend unchanged.

🎯 Quick Quiz

Question 1: A client sends GET /api/tasks/999 for an id that doesn't exist. What status code should the server return?

Question 2: Why does the plain-PHP build persist tasks to a file while the Node and Flask builds lose them on restart?

Question 3: In the Express build, what does app.use(express.json()) do?

📚 Further Reading

🚀 What's Next?

You've now built raw backends by hand. Next we go deeper into the JavaScript stack with a proper framework tour, starting with an overview of Express.js — the same tool you used today, examined feature by feature.

🎉 Weekend well spent!

Three servers, one contract, zero mystery. You can read a backend in any language now — that's a real skill.