aneurinandClaude Sonnet 5 c4c947896e Drop the description from the lists index
The all-lists view now shows only the title and item count per list; the
description lives on the list detail page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 19:13:19 +01:00
2026-09-03 17:35:10 +01:00
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 SQLite file, plus a Vue 3 + TypeScript PWA frontend in 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 Frontend lists view — list index + create form done
5 Frontend list detail — items UI with drag-and-drop reorder done

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.

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).

Run without Docker

Requires PHP 8.1+ with the pdo_sqlite and mbstring extensions, plus Composer. On Fedora:

sudo dnf install php-cli php-pdo php-mbstring composer

Setup

composer install
cp .env.example .env      # optional; sane defaults are used without it
composer migrate          # creates storage/database.sqlite and its tables

Running

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/ and talks to this API. With the API running (docker compose up -d):

cd web
npm install
npm run dev        # http://localhost:5173, proxies /api to localhost:8080

Or run it inside Compose alongside the API:

docker compose --profile frontend up -d

Unauthenticated visitors are redirected to /login; /register creates an account and signs in immediately. See web/README.md.

Configuration

All settings are optional environment variables (read from .env or the real environment). See .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

{ "status": "ok" }

POST /api/auth/register

Request:

{ "email": "ada@example.com", "password": "correct horse battery staple" }

201 Created:

{
  "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 872 characters.

POST /api/auth/login

Request:

{ "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:

{
  "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, sorted A→Z by title
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)

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.

Create/update body: title (required on create, 1255 chars), description (optional, ≤ 2000 chars, defaults to ""). PATCH needs at least one field.

List representation:

{
  "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
PUT /api/lists/{id}/items/order reorder all items in one shot
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, 11000 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.

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.

Item representation:

{
  "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:

{ "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

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

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.

S
Description
No description provided
Readme
588 KiB
Languages
PHP 61%
Vue 21.4%
TypeScript 8.1%
CSS 7.8%
Dockerfile 1.3%
Other 0.4%