There is now one way in: POST /api/auth/magic-link with an email address. It
creates the account (unverified) if the address is new -- that's the only
"sign up" -- and emails a sign-in link either way, subject to the existing
60s-per-user resend throttle. Opening the link (POST /api/auth/verify-email,
unchanged) is what actually creates the session, and marks the address
verified the first time. Since a session can now only ever come from an
opened link, "authenticated" implies "verified" -- there's no more
authenticated-but-unverified state, so the resend-verification endpoint and
all the "verify your email" nagging UI are gone too.
Backend
- migrations/008: ALTER TABLE users DROP COLUMN password_hash.
- UserRepository: create() takes only an email; new findOrCreateByEmail()
(race-safe) backs the magic-link endpoint.
- AuthController: register()/login() removed; requestLoginLink() now
find-or-creates before sending.
- EmailVerificationController: resend() removed (dead -- you can't be
authenticated and unverified); requestChange() drops the password check,
now just { email }.
- EmailVerifier: sendVerification() removed (unused once register() and
resend() are gone); sendLoginLink() is the one email people get.
- Routes: POST /auth/register, POST /auth/login, POST /email/verification
all gone.
Frontend
- LoginView: email field + "Send sign-in link" button, nothing else.
RegisterView and the /register route are gone.
- auth store: register()/login()/resendVerification() removed;
requestEmailChange() drops the password param.
- ProfileView: password field and the "verify your email" section removed,
leaving just the change-email form.
- App.vue: the "verify email" header badge is gone; DashboardView's
unverified-address notice is gone.
- Now-dead .badge/.badge--warn/a.badge CSS removed.
Tests: AuthTest and EmailVerificationTest rewritten for the new flow (52
tests total, down from 58 -- consolidated, not reduced coverage).
ApiTestCase::authHeader() signs in via the real magic-link -> verify flow.
Verified end-to-end against the rebuilt container and the dev server: a brand
new address gets an account + session from one link; /auth/register,
/auth/login and /email/verification all 404; the UI shows no password field
anywhere and no verification nagging. Also fixed the README's "Try it" curl
snippets, which had been silently broken since JSON_PRETTY_PRINT was added
(grep patterns didn't tolerate the space after ':').
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
16 KiB
PHP Project Manager
A small project-management application: a REST API written in PHP (Slim 4) backed by an SQLite file, plus a Vue 3 + TypeScript PWA frontend in web/. Each user owns projects, and each project holds ordered cards.
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 | Project + card CRUD API | ✅ done |
| 4 | Frontend projects view — project index + create form | ✅ done |
| 5 | Frontend project detail — cards UI with drag-and-drop reorder | ✅ done |
| 6 | Project 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 |
| 9 | Per-project card statuses ("To do" / "Doing" / "Done"); status chip, new cards start with none | ✅ done |
| 10 | Project view — full-width, tabbed: alphabetical "All tasks" list + "Kanban" board, per-column drag ordering | ✅ done |
| 11 | Persistent left sidebar (Dashboard link + project list/new-project form); dashboard = grid of projects with their "New" inbox cards | ✅ done |
| 12 | Passwordless-only auth — registration and password login removed; a magic link is the sole way in, and creates the account if needed | ✅ done |
There is no password. Signing in is entering an email address and opening the magic link sent to it — the same step creates the account the first time. See Auth.
Run with Docker
The only requirement is Docker with the Compose plugin.
docker compose up -d
This runs a multi-stage build — a Node stage compiles the Vue frontend, then a
PHP 8.3 + Apache stage bakes in the PHP source and the built SPA — applies
migrations, and serves the whole app at http://localhost:8080: the SPA at /
(assets and all) and the REST API under /api (e.g.
curl http://localhost:8080/api/health). Unknown paths fall back to the SPA
shell for client-side routing.
- 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 image is the artifact: PHP source and the compiled frontend are copied in
at build time, not bind-mounted. Rebuild to pick up any code change:
docker compose up -d --build. For iterating on the frontend, run the Vite dev server on the host instead (see Frontend). - The SQLite database and the generated JWT signing key live in the
storagenamed volume, mounted at/var/www/storage, so they survivedocker compose restart/down+up. 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 API as long as the document root is public/ and
unknown paths fall through to public/index.php. public/.htaccess also serves
a built frontend from public/ (copy web/dist/ there) and only falls back to
index.php for /api and when no index.html is present.
Frontend
The Vue/TypeScript PWA lives in web/. The production build is compiled
into the Docker image and served from the app container at /. For frontend
development, run the Vite dev server on the host — with the API container running
(docker compose up -d):
cd web
npm install
npm run dev # http://localhost:5173, proxies /api to localhost:8080
Unauthenticated visitors are redirected to /login — enter an email address
and open the link that arrives; there is no separate sign-up. 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:8080 |
Base URL used to build magic links (http://localhost:5173 for a host npm run dev) |
MAIL_TRANSPORT |
mail |
mail (PHP mail()), smtp, or log (append to a file) |
MAIL_FROM / MAIL_FROM_NAME |
no-reply@todo.test / Projects |
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" }
Auth
There is no password and no separate registration endpoint. Entering an email address and opening the link sent to it is the entire flow, for a brand-new address and a returning one alike.
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST |
/api/auth/magic-link |
— | email a one-time sign-in link, creating the account first if the address is new |
POST |
/api/auth/verify-email |
— | consume the token: sign in, and (the first time) mark the address verified, or apply a pending email change |
GET |
/api/me |
✔ | the current user |
POST |
/api/email/change |
✔ | request a deferred email change |
POST /api/auth/magic-link
Request: { "email": "ada@example.com" }.
Emails a one-time sign-in link (<APP_URL>/verify-email?token=…, 15-minute
expiry) and always returns 202 with the same message. If the address has no
account yet, one is created (unverified) right here — that's the only "sign
up" there is. A link is only actually (re-)sent if this address hasn't been
emailed one in the last 60 seconds. 422 if the address is malformed.
{ "message": "Check your email for a link to sign in." }
POST /api/auth/verify-email
Body: { "token": "..." }. A missing/invalid, already-used, or expired token is
400 (distinct messages). Success signs the caller in:
{
"user": {
"id": 1,
"email": "ada@example.com",
"email_verified": true,
"email_verified_at": "2026-09-03T12:00:00Z",
"pending_email": null,
"created_at": "2026-09-03T12:00:00Z"
},
"token": "<jwt>",
"expires_at": "2026-09-04T12:00:00+00:00"
}
Opening a link is the only way to obtain a session, so an authenticated request
is always for a verified address — email_verified is true from the first
token a user's browser ever holds.
GET /api/me
Requires Authorization: Bearer <jwt>. 200 OK: the same user object shown
above. 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.
POST /api/email/change
Requires Authorization: Bearer <jwt>. Body: { "email": "new@example.com" }.
The address must be free (409) and different from the current one (422).
Throttled to once per 60 seconds (shared with /api/auth/magic-link's
resend window, per user) — 429 with error.details.retry_after when too
soon. On success, 202 with retry_after and pending_email:
{
"message": "Confirmation email sent to the new address.",
"pending_email": "new@example.com",
"retry_after": 60
}
The change is not applied until the magic link sent to the new address is
opened — until then GET /api/me still shows the old address, with
pending_email set. Opening that link both changes the address and re-verifies
it, via the same /api/auth/verify-email.
Projects
All routes below require Authorization: Bearer <jwt>. A project belongs to one
owner (the creator); another user's project — or a missing one — always responds
404.
| Method | Path | Purpose |
|---|---|---|
GET |
/api/projects |
the caller's projects, sorted A→Z by title |
POST |
/api/projects |
create a project |
GET |
/api/projects/{id} |
one project |
PATCH |
/api/projects/{id} |
update title and/or description |
DELETE |
/api/projects/{id} |
delete the project and its cards (204) |
GET /api/projects is always ordered alphabetically (case-insensitive) by
title; there is no other sort option. A user may own at most 100 projects —
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.
Project representation:
{
"project": {
"id": 1,
"title": "Website relaunch",
"description": "Q3",
"owner_id": 1,
"card_count": 3,
"completed_count": 1,
"created_at": "2026-09-03T12:00:00Z",
"updated_at": "2026-09-03T12:00:00Z"
}
}
GET /api/projects returns { "projects": [ … ] }.
Creating a project also seeds it with three statuses — "To do", "Doing", "Done" (see Statuses).
Cards
Scoped to a project; the parent project's ownership is checked first
(404 otherwise).
| Method | Path | Purpose |
|---|---|---|
GET |
/api/projects/{id}/cards |
every card, grouped by column (inbox first) then position |
POST |
/api/projects/{id}/cards |
add a card |
PUT |
/api/projects/{id}/cards/order |
set the order/contents of one status column |
GET |
/api/projects/{id}/cards/{cardId} |
one card |
PATCH |
/api/projects/{id}/cards/{cardId} |
update text, complete, and/or status_id |
DELETE |
/api/projects/{id}/cards/{cardId} |
delete the card (204) |
Create body: text (required, 1–1000 chars), complete (optional bool,
default false). PATCH needs at least one field.
Ordering. position is a dense 0..n-1 rank within a column — the cards
that share a (project_id, status_id). The inbox (status_id IS NULL) is its
own column. New cards go to the end of the inbox. There is no project-wide order.
A new card has no status (status_id: null) — it sits in the project
"inbox" until the user gives it one. Two ways to move it:
PATCH …/cards/{cardId}withstatus_id(a status id in this project, ornullfor the inbox) — appends the card to the end of the destination column and re-packs the one it left.422for an unknown or foreign status.PUT …/cards/orderwith{ "status_id": <id|null>, "card_ids": [3, 1, 2] }— makes those cards the exact contents of that column, in that order (positions rewritten to0..n-1). Any card dragged in from another column is re-parented and its old column re-packed, all in one transaction.card_idsmust be distinct cards of this project and must include every card already in the target column (422otherwise). Returns{ "cards": [ … ] }for the whole project. This is what the kanban board calls on every drop.
A status row that is deleted clears itself from its cards rather than deleting them.
Card representation:
{
"card": {
"id": 10,
"project_id": 1,
"text": "Design homepage",
"complete": false,
"position": 0,
"status_id": null,
"status": null,
"created_at": "2026-09-03T12:00:00Z",
"updated_at": "2026-09-03T12:00:00Z"
}
}
status is the embedded { id, name } of the linked status, or null when the
card has none. GET …/cards returns { "cards": [ … ] }.
Statuses
Every project has an ordered set of card statuses, created with the project:
"To do", "Doing", "Done". They are project-specific — each project owns its own
rows. There is no create/update/delete for the statuses themselves yet; a card
is moved between them (or to the inbox) via PATCH …/cards/{cardId}.
| Method | Path | Purpose |
|---|---|---|
GET |
/api/projects/{id}/statuses |
the project's statuses, ordered by position |
{
"statuses": [
{ "id": 1, "project_id": 1, "name": "To do", "position": 0 },
{ "id": 2, "project_id": 1, "name": "Doing", "position": 1 },
{ "id": 3, "project_id": 1, "name": "Done", "position": 2 }
]
}
Requires Authorization: Bearer <jwt>; a project that is missing or not owned by
the caller responds 404.
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
Signing in needs the link the API emails, so this pulls it back out of the
bundled Mailpit catcher (adjust if you've pointed MAIL_TRANSPORT elsewhere).
The API pretty-prints its JSON, so responses are piped through tr -d ' \n'
before grep (Mailpit's own JSON doesn't need that):
BASE=http://localhost:8080
curl -s -X POST $BASE/api/auth/magic-link \
-H 'Content-Type: application/json' \
-d '{"email":"ada@example.com"}'
MSG_ID=$(curl -s "http://localhost:8025/api/v1/messages?limit=1" | grep -o '"ID":"[^"]*"' | head -1 | cut -d'"' -f4)
LINK_TOKEN=$(curl -s "http://localhost:8025/api/v1/message/$MSG_ID" | grep -o 'token=[a-f0-9]*' | head -1 | cut -d= -f2)
TOKEN=$(curl -s -X POST $BASE/api/auth/verify-email \
-H 'Content-Type: application/json' \
-d "{\"token\":\"$LINK_TOKEN\"}" | tr -d ' \n' | grep -o '"token":"[^"]*"' | cut -d'"' -f4)
curl -s $BASE/api/me -H "Authorization: Bearer $TOKEN"
PROJECT=$(curl -s -X POST $BASE/api/projects \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"title":"Website relaunch","description":"Q3"}' \
| tr -d ' \n' | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
curl -s $BASE/api/projects/$PROJECT/statuses -H "Authorization: Bearer $TOKEN"
curl -s -X POST $BASE/api/projects/$PROJECT/cards \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"text":"Design homepage"}'
curl -s $BASE/api/projects/$PROJECT/cards -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, Project, Card, CardStatus)
src/Repository/ Database access (User, EmailVerification, Project, Card, CardStatus)
src/Support/Validator.php Request-body validation helper
migrations/*.sql Schema, applied by bin/migrate.php
Dockerfile Multi-stage: Node frontend build + PHP 8.3/Apache runtime
docker-compose.yml Local stack: app (SPA + API) + Mailpit
docker/ Apache vhost + container entrypoint
web/ Vue 3 + TypeScript + Vite PWA frontend (dev on the host)
Provenance
This project was generated with Claude Code.