Two migrations add todo_lists (owner_id FK to users, title, description) and todo_items (list_id FK, text, complete, position), both with ON DELETE CASCADE. New endpoints under /api/lists, all behind AuthMiddleware: - lists: index / store / show / update (PATCH) / destroy - items: nested under a list, same five verbs Lists are owner-scoped — another user's or a missing list responds 404, never 403. New items append after the highest position unless one is given; the list carries item_count / completed_count. Item PATCH is partial and never renumbers siblings. Adds App\Support\Validator for request-body checks, TodoList/TodoItem repositories, and body()/user() helpers on the Controller base. Feature tests move their shared harness into tests/ApiTestCase; TodoTest covers CRUD, ownership isolation, ordering, completion counts, validation and cascade delete. Full suite: 15 passing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
314 lines
9.0 KiB
Markdown
314 lines
9.0 KiB
Markdown
# PHP Todo List
|
||
|
||
A small todo-list application: a REST API written in PHP (Slim 4) backed by an
|
||
SQLite file, plus a Vue 3 + TypeScript PWA frontend in [web/](web/).
|
||
|
||
## Status
|
||
|
||
| Stage | Scope | State |
|
||
|-------|-------|-------|
|
||
| 1 | Auth API — register, login, `GET /me` | ✅ done |
|
||
| 2 | Frontend shell — Vite PWA, auth-gated routing, register/login pages | ✅ done |
|
||
| 3 | Todo list + item CRUD API | ✅ done |
|
||
| 4 | Todo UI in the frontend | planned |
|
||
|
||
Registration signs the user in immediately, with the account's email marked
|
||
unverified (`user.email_verified` is `false` until a future stage adds a
|
||
verification endpoint).
|
||
|
||
## Run with Docker
|
||
|
||
The only requirement is Docker with the Compose plugin.
|
||
|
||
```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`).
|
||
|
||
- 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`).
|
||
- The SQLite database and the generated JWT signing key live in the `storage`
|
||
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`.
|
||
- `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:
|
||
|
||
```bash
|
||
sudo dnf install php-cli php-pdo php-mbstring composer
|
||
```
|
||
|
||
### Setup
|
||
|
||
```bash
|
||
composer install
|
||
cp .env.example .env # optional; sane defaults are used without it
|
||
composer migrate # creates storage/database.sqlite and its tables
|
||
```
|
||
|
||
### Running
|
||
|
||
```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`.
|
||
|
||
## 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).
|
||
|
||
## 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 |
|
||
|
||
## 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
|
||
{
|
||
"user": {
|
||
"id": 1,
|
||
"email": "ada@example.com",
|
||
"email_verified": false,
|
||
"email_verified_at": null,
|
||
"created_at": "2026-09-03T12:00:00Z"
|
||
},
|
||
"token": "<jwt>",
|
||
"expires_at": "2026-09-04T12:00:00+00:00"
|
||
}
|
||
```
|
||
|
||
New accounts are created with an unverified email (`email_verified: false`).
|
||
|
||
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
|
||
{
|
||
"user": {
|
||
"id": 1,
|
||
"email": "ada@example.com",
|
||
"email_verified": false,
|
||
"email_verified_at": null,
|
||
"created_at": "2026-09-03T12:00:00Z"
|
||
}
|
||
}
|
||
```
|
||
|
||
`401` if the header is missing, malformed, or the token is invalid/expired.
|
||
|
||
### 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 |
|
||
|--------|------|---------|
|
||
| `GET` | `/api/lists` | the caller's lists, newest first |
|
||
| `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`) |
|
||
|
||
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 |
|
||
| `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.
|
||
|
||
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": [ … ] }`.
|
||
|
||
### 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"
|
||
|
||
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"
|
||
```
|
||
|
||
## 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
|
||
src/Http/JsonErrorHandler.php Uniform JSON error envelope
|
||
src/Http/Controllers/ Request handlers (Auth, TodoList, TodoItem)
|
||
src/Repository/ Database access (User, TodoList, TodoItem)
|
||
src/Support/Validator.php Request-body validation helper
|
||
migrations/*.sql Schema, applied by bin/migrate.php
|
||
Dockerfile PHP 8.3 + Apache image
|
||
docker-compose.yml One-command local stack (API; web via --profile frontend)
|
||
docker/ Apache vhost + container entrypoint
|
||
web/ Vue 3 + TypeScript + Vite PWA frontend
|
||
```
|
||
|
||
## Provenance
|
||
|
||
This project was generated with [Claude Code](https://claude.com/claude-code).
|