2026-09-03 17:35:10 +01:00
# PHP Todo List
A small todo-list application: a REST API written in PHP (Slim 4) backed by an
2026-09-03 18:12:31 +01:00
SQLite file, plus a Vue 3 + TypeScript PWA frontend in [web/ ](web/ ).
2026-09-03 17:35:10 +01:00
## Status
| Stage | Scope | State |
|-------|-------|-------|
| 1 | Auth API — register, login, `GET /me` | ✅ done |
2026-09-03 18:12:31 +01:00
| 2 | Frontend shell — Vite PWA, auth-gated routing, register/login pages | ✅ done |
2026-09-03 18:30:52 +01:00
| 3 | Todo list + item CRUD API | ✅ done |
2026-09-03 18:42:15 +01:00
| 4 | Frontend lists view — list index + create form | ✅ done |
2026-09-03 18:59:51 +01:00
| 5 | Frontend list detail — items UI with drag-and-drop reorder | ✅ done |
2026-09-03 20:04:49 +01:00
| 6 | List view — inline title/description editing, delete via a Manage menu | ✅ done |
| 7 | Email verification (magic links) + profile page (resend, change email) | ✅ done |
2026-09-03 18:12:31 +01:00
2026-09-03 20:04:49 +01:00
Registration signs the user in immediately and emails a magic link that verifies
the address; `user.email_verified` stays `false` until the link is opened. See
[Email verification ](#email-verification--profile ).
2026-09-03 17:35:10 +01:00
2026-09-03 17:45:35 +01:00
## Run with Docker
2026-09-03 17:35:10 +01:00
2026-09-03 17:45:35 +01:00
The only requirement is Docker with the Compose plugin.
2026-09-03 17:35:10 +01:00
2026-09-03 17:45:35 +01:00
```bash
docker compose up -d
```
This builds a PHP 8.3 + Apache image, applies migrations, and serves the API at
<http://localhost:8080> (e.g. `curl http://localhost:8080/api/health` ).
2026-09-03 17:51:56 +01:00
- The project directory is bind-mounted into the container, so editing PHP
source takes effect without a rebuild (within ~2s, due to the opcache
revalidation interval). `vendor/` is used from the host — run `composer`
once first if it is missing (see "Run without Docker" below, or
`docker compose run --rm --entrypoint composer app install` ).
2026-09-03 17:45:35 +01:00
- The SQLite database and the generated JWT signing key live in the `storage`
2026-09-03 17:51:56 +01:00
named volume, mounted at `/var/www/storage` (outside the bind-mounted source),
so they survive `docker compose restart` / `down` + `up` .
- Rebuild only after changing the `Dockerfile` : `docker compose up -d --build` .
2026-09-03 17:45:35 +01:00
- `docker compose down -v` removes the volume and gives you a clean database.
- Override settings via the environment or a `.env` file in this directory
(Compose substitutes `APP_DEBUG` , `JWT_SECRET` , `JWT_TTL` — see
[docker-compose.yml ](docker-compose.yml )).
## Run without Docker
Requires PHP 8.1+ with the `pdo_sqlite` and `mbstring` extensions, plus
[Composer ](https://getcomposer.org/ ). On Fedora:
2026-09-03 17:35:10 +01:00
```bash
sudo dnf install php-cli php-pdo php-mbstring composer
```
2026-09-03 17:45:35 +01:00
### Setup
2026-09-03 17:35:10 +01:00
```bash
composer install
cp .env.example .env # optional; sane defaults are used without it
composer migrate # creates storage/database.sqlite and its tables
```
2026-09-03 17:45:35 +01:00
### Running
2026-09-03 17:35:10 +01:00
```bash
composer serve # http://localhost:8080 (php -S localhost:8080 -t public)
```
Any web server can serve the app as long as the document root is `public/` and
unknown paths fall through to `public/index.php` .
2026-09-03 18:12:31 +01:00
## Frontend
The Vue/TypeScript PWA lives in [web/ ](web/ ) and talks to this API. With the API
running (`docker compose up -d` ):
```bash
cd web
npm install
npm run dev # http://localhost:5173, proxies /api to localhost:8080
```
Or run it inside Compose alongside the API:
```bash
docker compose --profile frontend up -d
```
Unauthenticated visitors are redirected to `/login` ; `/register` creates an
account and signs in immediately. See [web/README.md ](web/README.md ).
2026-09-03 17:35:10 +01:00
## Configuration
All settings are optional environment variables (read from `.env` or the real
environment). See [.env.example ](.env.example ).
| Variable | Default | Purpose |
|----------|---------|---------|
| `APP_DEBUG` | `false` | Include exception details in error responses |
| `DATABASE_PATH` | `storage/database.sqlite` | SQLite file location |
| `JWT_SECRET` | auto-generated into `storage/secret.key` | Token signing key |
| `JWT_TTL` | `86400` | Token lifetime in seconds |
2026-09-03 20:04:49 +01:00
| `APP_URL` | `http://localhost:5173` | Frontend base URL used to build magic links |
| `MAIL_TRANSPORT` | `mail` | `mail` (PHP `mail()` ), `smtp` , or `log` (append to a file) |
| `MAIL_FROM` / `MAIL_FROM_NAME` | `no-reply@localhost` / `Todo List` | Envelope sender |
| `MAIL_LOG_PATH` | `storage/mail.log` | Where `log` transport writes |
| `MAIL_SMTP_HOST` / `_PORT` / `_USERNAME` / `_PASSWORD` / `_ENCRYPTION` | — / `587` / — / — / `tls` | Used only when `MAIL_TRANSPORT=smtp` |
SMTP is opt-in; without it the API falls back to PHP's `mail()` . The Docker
Compose setup sets `MAIL_TRANSPORT=log` (the container has no MTA) — read the
links with `docker compose exec app cat /var/www/storage/mail.log` .
2026-09-03 17:35:10 +01:00
## API
Base path: `/api` . All request and response bodies are JSON; send
`Content-Type: application/json` .
### `GET /api/health`
```json
{ "status" : "ok" }
```
### `POST /api/auth/register`
Request:
```json
{ "email" : "ada@example.com" , "password" : "correct horse battery staple" }
```
`201 Created` :
```json
{
2026-09-03 18:12:31 +01:00
"user" : {
"id" : 1 ,
"email" : "ada@example.com" ,
"email_verified" : false ,
"email_verified_at" : null ,
2026-09-03 20:04:49 +01:00
"pending_email" : null ,
2026-09-03 18:12:31 +01:00
"created_at" : "2026-09-03T12:00:00Z"
},
2026-09-03 17:35:10 +01:00
"token" : "<jwt>" ,
"expires_at" : "2026-09-04T12:00:00+00:00"
}
```
2026-09-03 18:12:31 +01:00
New accounts are created with an unverified email (`email_verified: false` ).
2026-09-03 17:35:10 +01:00
Errors: `422` invalid input, `409` email already registered.
Validation: `email` must be a valid address (≤ 255 chars); `password` must be
8– 72 characters.
### `POST /api/auth/login`
Request:
```json
{ "email" : "ada@example.com" , "password" : "correct horse battery staple" }
```
`200 OK` : same shape as register. `401` on bad credentials (the message does not
say whether it was the email or the password that was wrong).
### `GET /api/me`
Requires `Authorization: Bearer <jwt>` .
`200 OK` :
```json
2026-09-03 18:12:31 +01:00
{
"user" : {
"id" : 1 ,
"email" : "ada@example.com" ,
"email_verified" : false ,
"email_verified_at" : null ,
2026-09-03 20:04:49 +01:00
"pending_email" : null ,
2026-09-03 18:12:31 +01:00
"created_at" : "2026-09-03T12:00:00Z"
}
}
2026-09-03 17:35:10 +01:00
```
2026-09-03 20:04:49 +01:00
`pending_email` is the address a still-valid email-change link is waiting on, or
`null` .
2026-09-03 17:35:10 +01:00
`401` if the header is missing, malformed, or the token is invalid/expired.
2026-09-03 20:04:49 +01:00
### Email verification & profile
Registration emails a magic link — `<APP_URL>/verify-email?token=<opaque>` — that
expires **15 minutes** after it is sent. Only a hash of the token is stored.
| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| `POST` | `/api/auth/verify-email` | — | consume a token: verify the address (or apply a pending change), then return a session so the caller is logged in |
| `POST` | `/api/email/verification` | ✔ | resend the verification email; `409` if already verified |
| `POST` | `/api/email/change` | ✔ | request a **deferred** email change |
`POST /api/auth/verify-email` body: `{ "token": "..." }` . Success returns the
same `{ user, token, expires_at }` envelope as login. A missing/invalid, already
used, or expired token is `400` (distinct messages).
`POST /api/email/verification` and `/api/email/change` are throttled to **once
per 60 seconds** per user (shared window). When throttled they return `429` with
`error.details.retry_after` (seconds). On success they return `202` with
`retry_after` , and `/api/email/change` also returns `pending_email` .
`POST /api/email/change` body: `{ "email": "new@example.com", "password": "<current>" }` .
The current password is required (`422` if wrong). The address must be free
(`409` ) and different from the current one (`422` ). The change is **not applied
until** the magic link sent to the new address is opened — until then `GET
/api/me` shows the old address with `pending_email` set.
2026-09-03 18:30:52 +01:00
### Todo lists
All routes below require `Authorization: Bearer <jwt>` . A list belongs to one
owner (the creator); another user's list — or a missing one — always responds
`404` .
| Method | Path | Purpose |
|--------|------|---------|
2026-09-03 18:42:15 +01:00
| `GET` | `/api/lists` | the caller's lists, sorted A→Z by title |
2026-09-03 18:30:52 +01:00
| `POST` | `/api/lists` | create a list |
| `GET` | `/api/lists/{id}` | one list |
| `PATCH` | `/api/lists/{id}` | update `title` and/or `description` |
| `DELETE` | `/api/lists/{id}` | delete the list and its items (`204` ) |
2026-09-03 18:42:15 +01:00
`GET /api/lists` is always ordered alphabetically (case-insensitive) by title;
there is no other sort option. A user may own at most **100 lists** — creating
one beyond that responds `409` .
2026-09-03 18:30:52 +01:00
Create/update body: `title` (required on create, 1– 255 chars), `description`
(optional, ≤ 2000 chars, defaults to `""` ). `PATCH` needs at least one field.
List representation:
```json
{
"list" : {
"id" : 1 ,
"title" : "Shopping" ,
"description" : "For the week" ,
"owner_id" : 1 ,
"item_count" : 3 ,
"completed_count" : 1 ,
"created_at" : "2026-09-03T12:00:00Z" ,
"updated_at" : "2026-09-03T12:00:00Z"
}
}
```
`GET /api/lists` returns `{ "lists": [ … ] }` .
### Todo items
Scoped to a list; the parent list's ownership is checked first (`404` otherwise).
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/lists/{id}/items` | items, ordered by `position` then `id` |
| `POST` | `/api/lists/{id}/items` | add an item |
2026-09-03 18:59:51 +01:00
| `PUT` | `/api/lists/{id}/items/order` | reorder all items in one shot |
2026-09-03 18:30:52 +01:00
| `GET` | `/api/lists/{id}/items/{itemId}` | one item |
| `PATCH` | `/api/lists/{id}/items/{itemId}` | update `text` , `complete` , and/or `position` |
| `DELETE` | `/api/lists/{id}/items/{itemId}` | delete the item (`204` ) |
Create body: `text` (required, 1– 1000 chars), `complete` (optional bool,
default `false` ), `position` (optional integer ≥ 0; when omitted the item is
appended after the current highest position). `PATCH` needs at least one field.
`position` is a plain sort key the client manages — updating one item never
renumbers its siblings.
2026-09-03 18:59:51 +01:00
`PUT …/items/order` takes `{ "item_ids": [3, 1, 2] }` — every item in the list,
each exactly once (`422` otherwise). It rewrites positions to `0..n-1` in one
transaction and returns `{ "items": [ … ] }` in the new order. This is what the
drag-and-drop reorder in the UI calls.
2026-09-03 18:30:52 +01:00
Item representation:
```json
{
"item" : {
"id" : 10 ,
"list_id" : 1 ,
"text" : "Milk" ,
"complete" : false ,
"position" : 0 ,
"created_at" : "2026-09-03T12:00:00Z" ,
"updated_at" : "2026-09-03T12:00:00Z"
}
}
```
`GET …/items` returns `{ "items": [ … ] }` .
2026-09-03 17:35:10 +01:00
### Error shape
Every error response looks like:
```json
{ "error" : { "message" : "The submitted data was invalid." , "details" : { "email" : [ "Email must be a valid address." ] } } }
```
`details` is present only when relevant (e.g. validation).
## Try it
```bash
BASE = http://localhost:8080
curl -s -X POST $BASE /api/auth/register \
-H 'Content-Type: application/json' \
-d '{"email":"ada@example.com","password":"password123"}'
TOKEN = $( curl -s -X POST $BASE /api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"ada@example.com","password":"password123"}' | grep -o '"token":"[^"]*"' | cut -d'"' -f4)
curl -s $BASE /api/me -H "Authorization: Bearer $TOKEN "
2026-09-03 18:30:52 +01:00
LIST = $( curl -s -X POST $BASE /api/lists \
-H "Authorization: Bearer $TOKEN " -H 'Content-Type: application/json' \
-d '{"title":"Shopping","description":"For the week"}' \
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
curl -s -X POST $BASE /api/lists/$LIST /items \
-H "Authorization: Bearer $TOKEN " -H 'Content-Type: application/json' \
-d '{"text":"Milk"}'
curl -s $BASE /api/lists/$LIST /items -H "Authorization: Bearer $TOKEN "
2026-09-03 17:35:10 +01:00
```
## Tests
```bash
composer install # installs phpunit (require-dev)
vendor/bin/phpunit
```
## Layout
```
public/index.php Front controller
src/bootstrap.php App wiring and route definitions
src/Support/Config.php Environment-driven configuration
src/Support/Database.php PDO/SQLite connection
src/Auth/JwtService.php Issue/verify JWTs
src/Auth/AuthMiddleware.php Bearer-token authentication
2026-09-03 20:04:49 +01:00
src/Auth/SessionPayload.php Shared user + session JSON shape
src/Mail/ Mailer interface, SMTP/mail()/log transports, EmailVerifier
2026-09-03 17:35:10 +01:00
src/Http/JsonErrorHandler.php Uniform JSON error envelope
2026-09-03 20:04:49 +01:00
src/Http/Controllers/ Request handlers (Auth, EmailVerification, TodoList, TodoItem)
src/Repository/ Database access (User, EmailVerification, TodoList, TodoItem)
2026-09-03 18:30:52 +01:00
src/Support/Validator.php Request-body validation helper
2026-09-03 17:35:10 +01:00
migrations/*.sql Schema, applied by bin/migrate.php
2026-09-03 17:45:35 +01:00
Dockerfile PHP 8.3 + Apache image
2026-09-03 18:12:31 +01:00
docker-compose.yml One-command local stack (API; web via --profile frontend)
2026-09-03 17:45:35 +01:00
docker/ Apache vhost + container entrypoint
2026-09-03 18:12:31 +01:00
web/ Vue 3 + TypeScript + Vite PWA frontend
2026-09-03 17:35:10 +01:00
```
2026-09-03 17:37:20 +01:00
## Provenance
This project was generated with [Claude Code ](https://claude.com/claude-code ).