Platform admin
Internal Kanban board
The board at /admin/board is the platform team's own
Kanban for tracking shipped work, current effort, and the dormant
backlog. Internal-only — never visible to customers,
never reachable from a customer admin (super_admin middleware
returns 404 for any other role).
There is intentionally no nav entry in the admin
sidebar — the board is an internal-team tool, not something we
want even our own super-admin sidebar advertising. Navigate
directly via the URL (/admin/board) or this doc page.
Why it exists
Backlog items pile up over months — buyer feedback threads, design notes, postponed features. Without a single durable home, they leak into Slack, scratch files, or memory. The board is the durable home. Future engineering sessions read it as their starting point.
Columns
| Column | Use it for |
|---|---|
| Backlog | Anything we plan to do but haven't started. Most cards live here. |
| In progress | Cards someone is actively working on. Try to keep this small (1-3 items). |
| Review | Done coding, waiting on cross-check, deployment, or buyer confirmation. |
| Done | Shipped + verified. Stays for forensics; not auto-archived. |
Live updates
The board polls itself every 8 seconds via Inertia's partial
reload (router.reload({ only: ['tasks'] })) so two
super-admins editing the same board see each other's moves
without manually reloading. A small "Live · last synced Xs ago"
pill in the header confirms the polling is alive.
The hook (resources/js/hooks/use-board-live-sync.ts)
is memory-leak-safe by construction:
- Single-flight guard — a slow connection cannot pile up requests; the next tick is skipped while one is still in flight.
- Tab-visibility pause — backgrounded tabs do not poll. On focus, the hook fires immediately + re-arms the interval, so a returning user sees fresh data without waiting a full cycle.
- Dialog pause — when the Create / Edit dialog is open the hook stops polling so the form data the user is filling in is never replaced under their cursor by an incoming refresh. The pill switches to "Paused" while a dialog is up.
- Cleanup on unmount — interval ID and visibilitychange listener live inside the same effect; both are removed in the effect's cleanup so they cannot outlive the page.
If we ever wire an Echo client into the admin SPA (it's not there
today), this hook can be replaced with a Reverb subscription
on a private workspace channel — the rest of the page stays
unchanged because the hook returns the same
{ lastSyncedAt, isSyncing } shape regardless of
transport.
Sort order per column
Each column has its own ordering rule on the page render so the column you're scrolling shows the most useful card on top:
| Column | Sort | Why |
|---|---|---|
| Backlog | created_at ASC (oldest first) |
FIFO queue feel — items waiting longest float to the top so we don't lose track of them. |
| In progress | position ASC (manual) |
Admins drag-drop within Doing; respect the order they set. |
| Review | position ASC (manual) |
Same as Doing — manual order wins. |
| Done | updated_at DESC (latest-completed first) |
Most recently shipped cards are the most useful at a glance — recent commits, recent wins. |
Persistence
Cards live as a single JSON file at
storage/app/private/kanban-tasks.json (Laravel's local
disk) — not in the database. This is deliberate: it
protects the board across php artisan migrate:fresh
during development, which is the most common way teams accidentally
blow away in-progress tracking. The file survives schema resets,
factory tear-downs, and test transactions.
No new table, no migration when you want a new label / status / property — just edit the JSON shape and bump the controller validation. Cards are sorted server-side by (status order, position) so the page can render top-to-bottom in each column without touching the wire.
To back up or share the board, copy the JSON file. To wipe it, delete the file (the next read returns an empty board, lazily re-created on the next write). To restore a previous version, drop the file back in.
Seeding
The artisan command board:seed idempotently fills the
board with the post-Phase-1 backlog and the items shipped in this
session's Done column. Re-running it adds nothing — matching is by
title — so it's safe to bake into deployment steps.
php artisan board:seed
Ticket numbers
Every card carries a sequential #N assigned at
creation time (1, 2, 3, ...). Numbers never reuse — even after a
card is archived, the next new card picks up at
max(historical) + 1, not the freed slot. This makes
references stable: "look at #14" works the same way three months
later as it does today.
The number renders as a small monospace badge next to each card
title on the board UI. The CLI commands accept the number as a
short alias for the card — board:start 14,
board:done "#14", etc. Exact-title lookup still works
for backwards compatibility, but numbers are the recommended
reference style going forward.
Existing boards from before the numbering change get backfilled
automatically on the first read: legacy cards are assigned
numbers in created_at order. The persisted file
converges to the canonical shape on first access; nothing manual
is needed.
CLI workflow
Three artisan commands cover the lifecycle of a card from CLI — matching the project CLAUDE.md "Hard rules" #7 (capture) and #8 (close). Use these from inside Claude Code sessions or any terminal; opening the admin UI is optional.
Capture a new card
Run before touching source. Every task — postponed or about to be done right now — gets a card with a written plan first.
php artisan board:add "Add Paddle gateway" --body="$(cat <<'EOT'
## Why
EU/UK buyers prefer Paddle for VAT/MoR handling — currently they
have to set up Stripe Tax separately, which several CodeCanyon
buyers have flagged.
## Plan
1. New PaddleProductSync mirroring StripeProductSync.
2. Add paddle_product_id + paddle_plan_id columns to plans.
3. Wire CheckoutController to dispatch on gateway === 'paddle'.
4. Add admin gateway toggle in Settings → System.
## Files
- app/Services/Billing/PaddleProductSync.php (new)
- database/migrations/...
- app/Http/Controllers/Billing/CheckoutController.php
## Constraints
- Idempotent sync on product/plan IDs.
- Same gateway-registry pattern as Stripe/PayPal/Razorpay.
EOT
)" --label=billing --label=eu
Idempotent on title — re-running the same command does nothing.
Status defaults to backlog; labels are repeatable
flags. The body's ## Plan is the contract: the user
reads it before any code is written and redirects if needed.
Cards without a plan body are not real cards — they're chat
notes pretending to be tickets.
Move a card into Doing
# By number (recommended):
php artisan board:start 14
# Or by exact title:
php artisan board:start "Add Paddle gateway"
Run before code changes. The board's "In progress" column should accurately reflect what's actively being worked on right now. Idempotent — already-doing cards report a no-op.
Close a card with a UI test plan
php artisan board:done 14 \
--test="$(cat <<'EOT'
1. Sign in as super_admin
2. Open /admin/plans
3. Click "Sync to Paddle" on the Pro row
4. Confirm the modal shows "Synced — Paddle plan up to date"
5. Reload; confirm the gateway badge stays green
EOT
)" \
--release=v1.1.0
--test is mandatory — the command
refuses to close a card without it. The plan is appended to the
card body under a ## How to test from UI heading;
the original context written when the card was added stays
intact. Idempotent: re-running with a new plan replaces the prior
section, never duplicates the heading.
Test steps must be UI-driven, not "assert X in code". Start from "sign in as <role>" so a reviewer (or future-you) can follow them cold without context.
Release tags
--release=<tag> stamps the application version
a card shipped in onto the card row (renders as a small green
pill next to the title on the board). Pass it whenever you close
a card so the team can glance at Done and see "what landed in
v1.1.0?" without running git log.
If you forgot to pass --release at close time, the
bulk-stamp command labels every Done card that doesn't already
carry a version:
# Stamp every Done card with v1.1.0 (idempotent — skips cards that
# already have a tag):
php artisan board:stamp-version v1.1.0 --status=done
# Stamp specific cards by number / title:
php artisan board:stamp-version v1.1.0 --ref=23 --ref=34
# Force-overwrite an existing tag (e.g. if a card was wrongly stamped):
php artisan board:stamp-version v1.1.0 --status=done --force
Versions are free-form strings; we use semver-ish tags
(v1.1.0, v1.2.0-rc1) but the schema
doesn't enforce a format. The pill renders the literal value.
What it isn't
- Not a customer feature. Customers shouldn't see it.
- Not drag-and-drop in v1 — column moves go through the dropdown on the card-detail dialog. Drag-drop is a UI cost we'll add when there's a real need.
- Not multi-board. One platform board, period. If someone needs a per-workspace TODO list later, that goes in a separate table.