🛠️ 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/tasks | List all tasks | 200 OK |
GET | /api/tasks/:id | Fetch one task | 200 OK |
POST | /api/tasks | Create a task | 201 Created |
PUT | /api/tasks/:id | Update a task | 200 OK |
DELETE | /api/tasks/:id | Remove a task | 200 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 Requestwith{ "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:
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.
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)"]
| Milestone | Suggested time | Done when… |
|---|---|---|
| 1 — Node/Express | 60–90 min | All 5 endpoints work via curl |
| 2 — Python/Flask | 45–60 min | Same 5 endpoints, port 5000 |
| 3 — Plain PHP | 60–90 min | Same 5 endpoints, port 8000 |
| 4 — Test & compare | 30–45 min | Comparison 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
| Concern | Node / Express | Python / Flask | Plain PHP |
|---|---|---|---|
| Routing | app.get(path, fn) | @app.get(path) decorator | Hand-written switch |
| Parse JSON body | express.json() → req.body | request.get_json() | json_decode(php://input) |
| Path param | req.params.id | function argument | manual URL split |
| Set status code | res.status(201) | return data, 201 | http_response_code(201) |
| Data persistence | in-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
200for 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
200when a task wasn't found (should be404). - Forgetting
express.json()/get_json()soreq.bodyisundefined. - Creating a task with an empty title because validation checked "key exists" instead of "value is non-empty".
- Hard-coding an
idinstead 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
:3000and passes all five requests - ☐ Flask server runs on
:5000and passes all five requests - ☐ PHP server runs on
:8000and passes all five requests - ☐
POSTwith no title returns400on all three - ☐
GET /api/tasks/unknown-idreturns404on all three - ☐
PUTwith an invalid status returns400on all three - ☐ A created task has a generated
id, an ISOcreated_at, andstatus: "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 and404s, 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.