Backend
- POST /api/auth/magic-link (public): emails a one-time login link for an
address. Always 202 with the same body so accounts can't be enumerated; a
link is sent only when the account exists and wasn't emailed in the last
60s. Opening it (existing verify-email endpoint) returns a session and, as a
side effect, verifies the address. New EmailVerifier::sendLoginLink; the
60s interval is now EmailVerifier::RESEND_INTERVAL_SECONDS, shared.
Frontend
- LoginView defaults to magic-link mode: email only, "Log in with email". A
"Log in with password" link reveals the password field, changes the button
to "Log in", and itself becomes "Get a magic link" to switch back.
- VerifyEmailView copy is now login-neutral ("Signing you in").
Tests: 5 new (magic-link login, implicit verification, enumeration-safety,
throttle, validation). Suite: 37 passing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
14 KiB
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 |
| 6 | List view — inline title/description editing, delete via a Manage menu | ✅ done |
| 7 | Email verification (magic links) + profile page (resend, change email) | ✅ done |
| 8 | Passwordless login — magic-link by default, password login behind a toggle | ✅ done |
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.
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).
- A Mailpit container (the maintained MailHog
successor — one ~15 MB Go binary, messages kept in memory) also starts. The API
sends all email to it; read it at http://localhost:8025. Set
MAIL_TRANSPORT=mailor=smtp(withMAIL_SMTP_*) to send for real. - 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 — runcomposeronce first if it is missing (see "Run without Docker" below, ordocker compose run --rm --entrypoint composer app install). - The SQLite database and the generated JWT signing key live in the
storagenamed volume, mounted at/var/www/storage(outside the bind-mounted source), so they survivedocker compose restart/down+up. - Rebuild only after changing the
Dockerfile:docker compose up -d --build. docker compose down -vremoves the volume and gives you a clean database.- Override settings via the environment or a
.envfile in this directory (Compose substitutesAPP_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 |
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@todo.test / 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 |
Standalone, SMTP is opt-in and the API otherwise falls back to PHP's mail().
Under Docker Compose the default is MAIL_TRANSPORT=smtp pointed at the bundled
Mailpit container (mailpit:1025, no auth/TLS); open http://localhost:8025 to
read what was "sent".
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,
"pending_email": 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:
{ "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).
POST /api/auth/magic-link
Request: { "email": "ada@example.com" }.
Emails a one-time login link (<APP_URL>/verify-email?token=…, 15-minute
expiry). Always returns 202 with the same message regardless of whether the
address is registered, so accounts can't be enumerated; a link is only actually
sent when the account exists and hasn't been emailed in the last 60 seconds.
Opening the link (POST /api/auth/verify-email) signs the user in and verifies
the address if it wasn't already. 422 if the address is malformed.
GET /api/me
Requires Authorization: Bearer <jwt>.
200 OK:
{
"user": {
"id": 1,
"email": "ada@example.com",
"email_verified": false,
"email_verified_at": null,
"pending_email": null,
"created_at": "2026-09-03T12:00:00Z"
}
}
pending_email is the address a still-valid email-change link is waiting on, or
null.
401 if the header is missing, malformed, or the token is invalid/expired.
Email verification & profile
Magic links — <APP_URL>/verify-email?token=<opaque> — expire 15 minutes
after they are sent; only a hash of the token is stored. The same link/route
backs three things: verifying a new account, passwordless
login, and confirming an email change.
| 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/auth/magic-link |
— | email a passwordless login link (see above) |
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.
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, 1–255 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, 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.
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/Auth/SessionPayload.php Shared user + session JSON shape
src/Mail/ Mailer interface, SMTP/mail()/log transports, EmailVerifier
src/Http/JsonErrorHandler.php Uniform JSON error envelope
src/Http/Controllers/ Request handlers (Auth, EmailVerification, TodoList, TodoItem)
src/Repository/ Database access (User, EmailVerification, 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 Local stack: API + Mailpit; web via --profile frontend
docker/ Apache vhost + container entrypoint
web/ Vue 3 + TypeScript + Vite PWA frontend
Provenance
This project was generated with Claude Code.