Un-hard-wrap all Markdown documentation

Every prose paragraph and list item was manually wrapped at ~80-100
columns; joined each back into a single line. Headings, table rows,
and fenced code blocks are untouched -- tables already had one row per
line, and wrapping inside a code fence is the code's own formatting,
not something this applies to.

Also fixed two pre-existing typos this surfaced (both from wrapping
without leaving the space that was actually intended): a missing space
in "{ challenge_id, options }" and a stray "+ TypeScript" that had
accidentally been written as if it were a new line, in web/README.md
and README.md respectively.

Code comments are explicitly out of scope for this -- left exactly as
they were.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 02:29:08 +01:00
co-authored by Claude Sonnet 5
parent f1309b4c10
commit 5c8f2dbf07
7 changed files with 90 additions and 445 deletions
+5 -12
View File
@@ -1,15 +1,8 @@
# Documentation
Technical documentation for the PHP Project Manager. Start with the root
[README](../README.md) if you just want to run the app.
Technical documentation for the PHP Project Manager. Start with the root [README](../README.md) if you just want to run the app.
- **[API reference](api.md)** — every REST endpoint: auth (magic links,
passkeys), projects, cards, statuses, error shapes, and a curl walkthrough.
- **[Architecture](architecture.md)** — backend layout and how the pieces fit
together. Frontend architecture lives in
[web/README.md](../web/README.md) instead, since it's a large enough topic
on its own.
- **[Setup & configuration](setup.md)** — running without Docker, every
environment variable, and the test suite.
- **[Development history](history.md)** — a stage-by-stage log of how the app
got here.
- **[API reference](api.md)** — every REST endpoint: auth (magic links, passkeys), projects, cards, statuses, error shapes, and a curl walkthrough.
- **[Architecture](architecture.md)** — backend layout and how the pieces fit together. Frontend architecture lives in [web/README.md](../web/README.md) instead, since it's a large enough topic on its own.
- **[Setup & configuration](setup.md)** — running without Docker, every environment variable, and the test suite.
- **[Development history](history.md)** — a stage-by-stage log of how the app got here.
+25 -113
View File
@@ -1,7 +1,6 @@
# API reference
Base path: `/api`. All request and response bodies are JSON; send
`Content-Type: application/json`.
Base path: `/api`. All request and response bodies are JSON; send `Content-Type: application/json`.
### `GET /api/health`
@@ -11,10 +10,7 @@ Base path: `/api`. All request and response bodies are JSON; send
## 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.
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 |
|--------|------|------|---------|
@@ -29,14 +25,7 @@ address and a returning one alike. A user can also register one or more
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 — 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.
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 — 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." }
@@ -44,8 +33,7 @@ address is malformed.
### `POST /api/auth/verify-email`
Body: `{ "token": "..." }`. A missing/invalid, already-used, or expired token is
`400` (distinct messages). Success signs the caller in:
Body: `{ "token": "..." }`. A missing/invalid, already-used, or expired token is `400` (distinct messages). Success signs the caller in:
```json
{
@@ -63,24 +51,15 @@ Body: `{ "token": "..." }`. A missing/invalid, already-used, or expired token is
}
```
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.
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.
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`:
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`:
```json
{
@@ -90,20 +69,11 @@ soon. On success, `202` with `retry_after` and `pending_email`:
}
```
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`.
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.
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 |
|--------|------|------|---------|
@@ -114,35 +84,16 @@ users, not a fleet of company-issued security keys.
| `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:
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.
- `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.
`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 <jwt>`. A project belongs to one
owner (the creator); another user's project — or a missing one — always responds
`404`.
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 |
|--------|------|---------|
@@ -152,9 +103,7 @@ owner (the creator); another user's project — or a missing one — always resp
| `PATCH` | `/api/projects/{id}` | rename the project (`title`) |
| `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`.
`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, 1255 chars).
@@ -176,17 +125,11 @@ Project representation:
`GET /api/projects` returns `{ "projects": [ … ] }`.
Creating a project also seeds it with three **statuses** — "To do", "Doing",
"Done" (see [Statuses](#statuses)).
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:
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 |
|--------|------|---------|
@@ -197,31 +140,15 @@ own id, rather than nested under a project:
| `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, 11000 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.
Create body (either creation route): `text` (required, 11000 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:
**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.
`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:
@@ -241,17 +168,11 @@ Card representation:
}
```
`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": [ … ] }`.
`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`).
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 |
|--------|------|---------|
@@ -270,15 +191,9 @@ have one; deleting the last one is rejected (`409`).
}
```
Requires `Authorization: Bearer <jwt>`; a project that is missing or not owned by
the caller responds `404`.
Requires `Authorization: Bearer <jwt>`; 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": <another status id> }` in
the same project; those cards are moved there and the status deleted, in one
transaction.
**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": <another status id> }` in the same project; those cards are moved there and the status deleted, in one transaction.
## Error shape
@@ -292,10 +207,7 @@ Every error response looks like:
## 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):
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
+4 -17
View File
@@ -1,8 +1,6 @@
# Architecture
A REST API in PHP 8 (Slim 4) over a single SQLite file, plus a Vue 3 +
TypeScript PWA frontend. Auth is a bearer JWT, obtained via a magic link or a
passkey (see [docs/api.md](api.md)) — there's no session store or cookie.
A REST API in PHP 8 (Slim 4) over a single SQLite file, plus a Vue 3 + TypeScript PWA frontend. Auth is a bearer JWT, obtained via a magic link or a passkey (see [docs/api.md](api.md)) — there's no session store or cookie.
## Backend layout
@@ -29,23 +27,12 @@ docker/ Apache vhost (mod_rewrite, document root) + entrypo
web/ Vue 3 + TypeScript + Vite PWA frontend (dev on the host)
```
A `ProjectScopedController` base class centralizes "look up a project owned
by the caller, or 404" for the controllers that need it (`Project`, `Card`,
`CardStatus`). Every table a request can reach is scoped to the
authenticated user one way or another — directly (`owner_id`/`user_id`) or
via a project that is.
A `ProjectScopedController` base class centralizes "look up a project owned by the caller, or 404" for the controllers that need it (`Project`, `Card`, `CardStatus`). Every table a request can reach is scoped to the authenticated user one way or another — directly (`owner_id`/`user_id`) or via a project that is.
## Frontend
The Vue/TypeScript PWA lives in [web/](../web/) and is a separate concern
with its own conventions (routing, state, styling, drag-and-drop). See
[web/README.md](../web/README.md) for all of that — this document only
covers the backend.
The Vue/TypeScript PWA lives in [web/](../web/) and is a separate concern with its own conventions (routing, state, styling, drag-and-drop). See [web/README.md](../web/README.md) for all of that — this document only covers the backend.
## Database
One SQLite file, migrated forward-only by `bin/migrate.php` from
`migrations/*.sql` (each applied file is recorded in a `schema_migrations`
table, so re-running is safe). See [docs/setup.md](setup.md) for how to run
migrations, and [docs/history.md](history.md) for how the schema and the
rest of the app got here.
One SQLite file, migrated forward-only by `bin/migrate.php` from `migrations/*.sql` (each applied file is recorded in a `schema_migrations` table, so re-running is safe). See [docs/setup.md](setup.md) for how to run migrations, and [docs/history.md](history.md) for how the schema and the rest of the app got here.
+1 -5
View File
@@ -1,10 +1,6 @@
# Development history
A stage-by-stage log of major features, in the order they landed. Each row
describes the app as it was *at that point* — later stages sometimes
superseded earlier ones (e.g. password login, added in stage 1, was removed
again in stage 12); see [docs/api.md](api.md) and
[web/README.md](../web/README.md) for how things work now.
A stage-by-stage log of major features, in the order they landed. Each row describes the app as it was *at that point* — later stages sometimes superseded earlier ones (e.g. password login, added in stage 1, was removed again in stage 12); see [docs/api.md](api.md) and [web/README.md](../web/README.md) for how things work now.
| Stage | Scope | State |
|-------|-------|-------|
+12 -41
View File
@@ -1,8 +1,6 @@
# Setup & configuration
For the quick version — just running the app — see the root
[README](../README.md#getting-started). This covers the rest: running
without Docker, every configuration option, and the test suite.
For the quick version — just running the app — see the root [README](../README.md#getting-started). This covers the rest: running without Docker, every configuration option, and the test suite.
## Run with Docker
@@ -12,35 +10,17 @@ 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 (Alpine-based; apk's own prebuilt packages rather than
compiling PHP from source, which is most of why the image is ~90MB rather
than several times that) 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.
This runs a multi-stage build — a Node stage compiles the Vue frontend, then a PHP 8.3 + Apache stage (Alpine-based; apk's own prebuilt packages rather than compiling PHP from source, which is most of why the image is ~90MB rather than several times that) 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](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 <http://localhost:8025>. 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 [web/README.md](../web/README.md)).
- 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`.
- 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 <http://localhost:8025>. 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 [web/README.md](../web/README.md)).
- 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 the repo root
(Compose substitutes `APP_DEBUG`, `JWT_SECRET`, `JWT_TTL` — see
[docker-compose.yml](../docker-compose.yml)).
- Override settings via the environment or a `.env` file in the repo root (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:
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
@@ -60,18 +40,13 @@ composer migrate # creates storage/database.sqlite and its tables
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.
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.
The frontend itself still needs its own toolchain — see
[web/README.md](../web/README.md).
The frontend itself still needs its own toolchain — 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).
All settings are optional environment variables (read from `.env` or the real environment). See [.env.example](../.env.example).
| Variable | Default | Purpose |
|----------|---------|---------|
@@ -89,10 +64,7 @@ environment). See [.env.example](../.env.example).
| `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".
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".
## Tests
@@ -101,5 +73,4 @@ composer install # installs phpunit (require-dev)
vendor/bin/phpunit
```
Each test run applies every file in `migrations/*.sql` to a fresh SQLite
database, so it always exercises the current schema from scratch.
Each test run applies every file in `migrations/*.sql` to a fresh SQLite database, so it always exercises the current schema from scratch.