# 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/](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 dropdown); dashboard = grid of project tiles + a "Create a project" tile | ✅ 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 | | 13 | Global inbox — cards can have no project; moved into the sidebar, drag in/out of any project's kanban columns | ✅ done | | 14 | New-project form moved to the dashboard; sidebar project list is now a switcher dropdown; Kanban is a project's default tab | ✅ done | | 15 | Passkeys (WebAuthn) — register from the profile page, sign in with one instead of a magic link; a dismissible notice nudges users with none | ✅ done | | 16 | Project configuration view — manage a project's statuses: add, drag to reorder, delete (reassigning any cards on it first) | ✅ 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](#auth). ## Run with Docker The only requirement is Docker with the Compose plugin. ```bash 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 : 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](https://mailpit.axllent.org/)** 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 . Set `MAIL_TRANSPORT=mail` or `=smtp` (with `MAIL_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](#frontend)). - The SQLite database and the generated JWT signing key live in the `storage` named volume, mounted at `/var/www/storage`, so they survive `docker compose restart` / `down` + `up`. - `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 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/](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`): ```bash 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](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 | | `APP_ALLOW_REGISTRATION` | `true` | When `false`, a magic link is only ever sent to an existing address — an unknown one is silently ignored, so no new accounts get created | | `MAGIC_LINK_RESEND_SECONDS` | `60` | Minimum gap before a magic link can be resent to the same address (sign-in or email-change). Docker Compose overrides this to `0`, so links resend immediately in development | | `APP_URL` | `http://localhost:8080` | Base URL used to build magic links (`http://localhost:5173` for a host `npm run dev`) | | `WEBAUTHN_RP_ID` | `APP_URL`'s host | Passkey relying party ID (domain). Must be `localhost` or a real domain over HTTPS — a LAN IP won't work | | `WEBAUTHN_RP_NAME` | `Projects` | Passkey relying party display name, shown in the browser/OS prompt | | `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 to read what was "sent". ## API Base path: `/api`. All request and response bodies are JSON; send `Content-Type: application/json`. ### `GET /api/health` ```json { "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. A user can also register one or more [passkeys](#passkeys) and use one instead, once signed in at least once. | 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 | | `POST` | `/api/auth/passkey/options` | — | a challenge for signing in with a passkey (see [Passkeys](#passkeys)) | | `POST` | `/api/auth/passkey/verify` | — | verify a passkey response and sign in | | `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 (`/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 — unless `APP_ALLOW_REGISTRATION=false`, in which case an unknown address is silently ignored (still `202`, nothing sent) and only an address that already has an account can sign in. 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. ```json { "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: ```json { "user": { "id": 1, "email": "ada@example.com", "email_verified": true, "email_verified_at": "2026-09-03T12:00:00Z", "pending_email": null, "has_passkey": false, "created_at": "2026-09-03T12:00:00Z" }, "token": "", "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 `. `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 `. 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`: ```json { "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`. ### Passkeys WebAuthn, via [lbuchs/webauthn](https://github.com/lbuchs/WebAuthn). A passkey is always registered as a **discoverable, user-verified** credential, which is what makes login usernameless: the browser prompts the signed-in device for whichever passkey it has for this site, with no email typed first. There's no attestation/provenance check (`'none'` format) — this only confirms "the same device that registered", the standard trust model for a public site's own users, not a fleet of company-issued security keys. | Method | Path | Auth | Purpose | |--------|------|------|---------| | `GET` | `/api/passkeys` | ✔ | list the caller's passkeys | | `POST` | `/api/passkeys/options` | ✔ | a registration challenge | | `POST` | `/api/passkeys` | ✔ | verify the browser's response and store the credential | | `DELETE` | `/api/passkeys/{id}` | ✔ | remove a passkey (`204`) | | `POST` | `/api/auth/passkey/options` | — | a login challenge (no email — discoverable) | | `POST` | `/api/auth/passkey/verify` | — | verify and sign in | Both `.../options` endpoints return `{ "challenge_id": 1, "options": { "publicKey": {…} } }` — `options.publicKey` is passed more or less directly to [`navigator.credentials.create()`](https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/create) / [`.get()`](https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/get) (binary fields travel as base64url strings; the frontend converts them — see [web/README.md](web/README.md)). `challenge_id` identifies a **single-use** challenge, good for 5 minutes, and must be sent back with the browser's response: - `POST /api/passkeys` body: `{ "challenge_id": 1, "credential": {…}, "label": "My laptop" }`. `credential` is `{ id, response: { clientDataJSON, attestationObject } }` (all base64url). `201` with the stored passkey (`{ id, label, created_at, last_used_at }` — never the credential id or public key) on success; `400` if the response doesn't check out, `409` if that credential is already registered. - `POST /api/auth/passkey/verify` body: `{ "challenge_id": 1, "credential": {…} }`, where `credential` also carries `authenticatorData`, `signature`, and `userHandle`. Success returns the same `{ user, token, expires_at }` envelope as `/api/auth/verify-email`. `401` if the credential isn't recognised or the signature doesn't check out. `user.has_passkey` (on every user object) is `true` once at least one is registered — that's what the frontend's "add a passkey" notice keys off. ### Projects All routes below require `Authorization: Bearer `. 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: ```json { "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](#statuses)). ### Cards A card either sits in its owner's **inbox** (`project_id` and `status_id` both `null`) or belongs to exactly one of their projects with a status in it (both set) — enforced by a database CHECK constraint, never one without the other. The inbox is global to the user, not per-project. Because a card may have no project, single-card and ordering routes are addressed globally, by the card's own id, rather than nested under a project: | Method | Path | Purpose | |--------|------|---------| | `GET` | `/api/projects/{id}/cards` | a project's cards, grouped by status then `position` | | `POST` | `/api/projects/{id}/cards` | add a card directly to the project | | `GET` | `/api/inbox/cards` | the caller's inbox | | `POST` | `/api/inbox/cards` | add a card to the inbox | | `GET` \| `PATCH` \| `DELETE` | `/api/cards/{cardId}` | one card, owner-scoped (`404` otherwise) | | `PUT` | `/api/cards/order` | set the order/contents of one column | Create body (either creation route): `text` (required, 1–1000 chars), `complete` (optional bool, default `false`). The project route also takes an optional `status_id`, appending the card to the end of that status (must belong to the project, else `422`) — omitted, it goes in the project's first status instead. `PATCH` accepts `text` and/or `complete` only — moving a card is done via the order route below, not PATCH. **Ordering.** `position` is a dense `0..n-1` rank *within a column* — the cards that share an `(owner, project, status)`. The inbox is its own column, per owner. `PUT /api/cards/order` sets one column's contents and order: ```json { "project_id": 5, "status_id": 12, "card_ids": [3, 1, 2] } ``` `project_id`/`status_id` are both `null` for the inbox, or both set to a project owned by the caller and one of its statuses (`404`/`422` otherwise). `card_ids` must be distinct cards owned by the caller and must include every card already in the target column (`422` otherwise); it rewrites positions to `0..n-1`. Any card in the list that wasn't already in that column is re-parented into it — moving it from another project's status, or the inbox, or vice versa — and the column it left is re-packed, all in one transaction. Returns `{ "cards": [ … ] }` for the new column. This is what dragging a card in the kanban board (or the sidebar's inbox) calls on every drop; moving a card from one project to another is just two calls, via the inbox in between. Card representation: ```json { "card": { "id": 10, "project_id": 1, "text": "Design homepage", "complete": false, "position": 0, "status_id": 2, "status": { "id": 2, "name": "Doing" }, "created_at": "2026-09-03T12:00:00Z", "updated_at": "2026-09-03T12:00:00Z" } } ``` `project_id` and `status_id` are `null` together for an inbox card. `status` is the embedded `{ id, name }` of the linked status, or `null`. `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, managed from the project's **configuration** view (create, reorder, delete). A project always keeps at least one status, since a project card must have one; deleting the last one is rejected (`409`). | Method | Path | Purpose | |--------|------|---------| | `GET` | `/api/projects/{id}/statuses` | the project's statuses, ordered by `position` | | `POST` | `/api/projects/{id}/statuses` | add one at the end — `{ "name": "Blocked" }` | | `PUT` | `/api/projects/{id}/statuses/order` | reorder — `{ "status_ids": [3, 1, 2] }`, every status once | | `DELETE` | `/api/projects/{id}/statuses/{statusId}` | delete (see below) | ```json { "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 `; a project that is missing or not owned by the caller responds `404`. **Deleting a status that still has cards** fails with `409` and `error.details.card_count` set, rather than silently orphaning them (a referenced status can't be deleted at the database level either — the FK is `ON DELETE RESTRICT`). Retry with `{ "reassign_to": }` in the same project; those cards are moved there and the status deleted, in one transaction. ### 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 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): ```bash 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 ```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/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, Passkey, Project, Card, CardStatus) src/Repository/ Database access (User, EmailVerification, Passkey, WebAuthnChallenge, 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](https://claude.com/claude-code).