A small, secure web application where users create and manage private notes with file attachments.
Stack: PHP 8.3 · Laravel 12 (Livewire starter kit for auth, Blade for the app) · PostgreSQL 16 · S3-compatible storage (RustFS) · Tailwind CSS · Pest
- Overview
- How to run
- Architecture
- Data model
- Security
- Testing
- AI collaboration
- Next steps / not done
- Workflow journal
This guide is made to show how I would explain my decisions in person. I go step by step.
I often stick to this planning pattern when building something, to be able to explain my decisions to managers and to improve cooperation with co-workers. That way a bug or design flaw can be caught and discussed early in the development process. I try to save time and avoid unnecessary back-and-forths.
In a real work scenario I would not write such a big README. Here I try to give as much insight into my workflow as possible, feeling this is the most honest approach.
The README is split in two parts:
- Reference (the sections below): what the app is and how it works.
- Workflow journal: how I got there, in chronological order.
This guide is mostly written by hand as the project progresses; I think it is worth the time investment.
Time: environment setup, planning and the empty skeleton (Steps 1–4 and the preparation part of Step 5) were prepared beforehand. The 2-hour clock starts right after the skeleton commit (2026-11-08, 19:48 EET) and covers implementation, tests, review and docs. Ended at 21:40, about 1h52m in total.
- PHP 8.3 with the
pdo_pgsqlextension, and inphp.ini:upload_max_filesize = 12M,post_max_size = 55M(PHP's defaults are below the app's 10 MB limit) - Composer 2
- Node.js 20.19+ (I use 22 LTS)
- PostgreSQL 16 (an empty database and a user that owns it)
- Any S3-compatible storage with a private bucket (I use RustFS; AWS S3 works with the same code)
My own setup: WampServer 3.4.0 on Windows (PHP 8.3.28), PostgreSQL 16 as a Windows service, RustFS 1.0.4 bound to
127.0.0.1. The step-by-step commands I used, including creating the database user and the bucket, are inbootstrap-process.md.
git clone <repo-url> secure-notes
cd secure-notes
composer install
npm install
npm run build # front-end assets (the tests need them too)
cp .env.example .env # PowerShell: copy .env.example .env
php artisan key:generateFill in .env:
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=secure_notes
DB_USERNAME=secure_notes
DB_PASSWORD=<your-db-password>
AWS_ACCESS_KEY_ID=<your-s3-access-key>
AWS_SECRET_ACCESS_KEY=<your-s3-secret-key>
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=secure-notes
AWS_ENDPOINT=http://127.0.0.1:9000
AWS_USE_PATH_STYLE_ENDPOINT=true
MAIL_MAILER=logThen:
php artisan migrate
composer run dev # app on http://127.0.0.1:8000Make sure the S3 storage is running before uploading (for RustFS: C:\rustfs\start-rustfs.bat).
php artisan testTests use SQLite in memory and a fake S3 disk, so they need no database or storage running.
Every request follows the same path:
Route (auth) → FormRequest (authorize via Policy, then validate) → Controller → Service → Model / S3 → Response
- Controllers are thin. They connect the pieces and return a response; no business logic.
- FormRequests (
NoteRequest,AttachmentRequest) hold all input validation. NotePolicyis the single source of truth for ownership. It answers "may I touch this note?" and therefore also "may I touch this attachment?", because an attachment belongs to exactly one note.AttachmentService(constructor-injected) owns everything that touches file storage: store, download, delete. Controllers never callStoragedirectly.config/attachments.phpholds the allowed file types and max size, used by both validation and the upload form, so they can never disagree.
Deliberately not added: a repository layer. Eloquent already is the data-access abstraction; another layer would add code without adding value at this size.
Storage abstraction in practice: the app talks to Laravel's s3 disk, not to a specific vendor. When MinIO stopped publishing binaries, switching to RustFS was a configuration change only. Moving to AWS S3 would be the same.
The full file structure, with the responsibility of each file and the route table, is in docs/file-structure.md.
The flow chart is in docs/project-flow.canvas (Obsidian). An exported image is shown in Step 2.
users id, name, email, password, timestamps (from the starter kit)
notes id (ULID), user_id → users (cascade), title, body, timestamps
attachments id (ULID), note_id → notes (cascade), disk, path,
original_name, mime_type, size, timestamps
- A user has many notes; a note has many attachments. Deleting a note deletes its attachments.
- ULIDs instead of auto-increment IDs, so URLs cannot be guessed (
/notes/01JBX7K3M9...instead of/notes/57). - Attachments have no
user_id. Ownership flows through the note, so there is one source of truth. - The original filename is stored only as data, never used as the storage path.
| Measure | Why |
|---|---|
| Starter-kit authentication (hashed passwords, session regeneration, login rate limiting, CSRF) | Security-critical auth code is not hand-rolled |
NotePolicy + every query scoped to the logged-in user |
Users can only see and change their own notes and attachments |
| 404, not 403, for other users' resources | Does not reveal that a resource exists |
Scoped route bindings (/notes/{note}/attachments/{attachment}) |
An attachment can only be reached through the note it belongs to |
| ULIDs in URLs | No ID enumeration; defense in depth on top of the policy |
| FormRequest validation on every input | Invalid data never reaches the controller |
| Upload allowlist (pdf, png, jpg, jpeg, webp, txt, docx), max 10 MB, checked by real MIME type | SVG/HTML are blocked because they can carry scripts (XSS) |
| Note validation: title required (max 255), body optional (max 10,000) | Bounded input; a note can be just a title with attachments |
| Random storage path, client filename never used as a path | Prevents path traversal and overwriting other files |
| Private bucket, downloads only through the app | Every download passes the policy; no public file URLs exist |
Content-Disposition: attachment + X-Content-Type-Options: nosniff |
The browser downloads files instead of executing them |
Note bodies rendered as escaped plain text ({{ }}) |
No stored XSS through note content |
| Separate, non-admin database user | Least privilege: the app cannot touch anything outside its own database |
Storage bound to 127.0.0.1 |
Not reachable from other machines on the network |
App served from public/ only (composer run dev) |
.env, source code and logs are never web-accessible |
Error pages without stack traces (APP_DEBUG=false outside development) |
No internal details leak to users |
Feature tests with Pest, on SQLite in memory, with Storage::fake('s3').
Covered:
- guests are redirected to login;
- user A gets 404 on user B's notes and attachments;
- note create / read / update / delete;
- uploads: valid file stored; oversized file and disallowed type rejected;
- deleting a note or an attachment removes the stored file.
Trade-off: SQLite is not PostgreSQL (e.g. LIKE case sensitivity, looser types). It keeps the tests fast and runnable right after cloning. In a real project I would run the suite against PostgreSQL in CI.
Result: 81 tests passing: 27 from the starter kit, 54 written by Codex from the spec. GitHub Actions runs them on every push (fixed in 48c0dee; earlier commits show ❌).
| Who | Role in this project |
|---|---|
| Claude Code | Planning, environment setup (with explicit permission), file structure, proposing implementation in small chunks; smoke test on the real stack |
| Codex | Writing tests independently from the spec; security review of the implementation |
| Me | Delegating tasks, reviewing every chunk, resolving disagreements between agents, owning the final quality |
- Agents do not write code into the project by default. They present code in small chunks; I review it and add it.
- Explicit, scoped permission is the exception. When it saves time, I allow an agent to write, naming the exact task. The permission does not carry over to the next task.
- Details, permissions granted, and issues caught in review:
AI_COLLABORATION.md.
- Email verification: mail is logged, not sent. I would need to choose an SMTP setup (e.g. Mailpit locally); too much for 2 hours.
- Presigned S3 URLs for direct downloads of large files.
- Inline viewing of images and PDFs. The brief allows "viewed or downloaded"; I chose forced download because a file can then never run in the browser.
- Background job to clean up orphaned files (a file can remain if an S3 delete fails after the database delete; such paths are already logged).
- Per-user storage quota (uploads are rate-limited, but total storage per user is not capped yet).
- Explicit private visibility on the S3 disk, in addition to the private bucket (anonymous access is verified to return 403).
- Virus scanning of uploads.
- CI against PostgreSQL: GitHub Actions already runs the full suite on every push (SQLite); a PostgreSQL service in CI would close the SQLite/Postgres gap.
- All-or-nothing note + files: if storage fails midway, the note is kept and an error is shown.
This step usually takes no time; it took me about 5 minutes.
This is mostly how the collaboration looks, in early steps:
I usually work with opencode and Codex. I use the "plan" functionality of opencode with the subscription-based, more expensive model, and rarely use the "build" functionality with the cheap or free models. Free/cheap agents build, smart and expensive agents only plan.
For this project I'm jumping back into Claude Code, older versions of which I have tried before; claude now also has the "plan/build" separation, so I think I'm missing out, this project is a good excuse to jump back in. I use Claude for planning and Codex for tests and smoke/canary testing, which I find it does well. I decided not to use Opencode at this point, maybe I will at step 7 (edit: I did not use it).
I have an AGENTS.md boilerplate used in most projects. It states that agents are never allowed to edit any file, and that they answer my questions in a specific manner: mostly code, short answers, human-readable code only, and discussing a bug fix first instead of jumping straight to code.
That way I can be sure to never blindly copy-paste or vibe code, and I also don't need to press "allow" every few seconds. I also define keywords I use when I need an agent to actually go ahead and edit files.
I did not create any new Codex/Claude skills, but I would normally consider it. For this project I will probably use a custom-made Codex skill that checks for changes on the Obsidian canvas (see Step 2) and verifies that the README is not missing anything. That skill runs a Node.js script to save tokens.
I use Obsidian to create a canvas, to wireframe a flow chart for the application: the project roadmap, the request flow of a secure download, and the data model. See docs/project-flow.canvas.
It looks like this:
- WampServer 3.4.0 with PHP 8.3: already in daily use; I only enabled the PostgreSQL drivers.
- PostgreSQL 16: the brief's preferred database. It runs on port 5432, next to my existing MySQL, without conflicts.
- RustFS for S3-compatible storage. MinIO was the first choice, but it stopped publishing free binaries in 2025 (its download now returns
410 Gone). Because the code only talks to thes3disk, the switch cost nothing. - Composer, pointed at WAMP's PHP 8.3.
- Node 22 LTS on Windows (nvm-windows). Kept on Windows next to PHP, because Node packages installed from WSL do not run on Windows.
- Not served by Apache. Serving the project as
localhost/secure-noteswould expose.env;composer run devserves onlypublic/. MAIL_MAILER=log: password-reset mails are written tostorage/logsinstead of sent.- Laravel 12 + Livewire starter kit, for authentication only. It gives tested register / login / logout / password reset / rate limiting / CSRF out of the box. I don't hand-roll security-critical auth code.
- Blade + controllers for my own pages (notes and attachments). Blade is Laravel's server-side templating: the server sends finished HTML, with no separate JS app. Classic controllers keep the request → validation → authorization → response flow explicit and easy to explain. Livewire is only used by the generated auth screens.
- No front-end framework for my pages. This is an MVP built in 2 hours that will never see production; a framework would add complexity without adding value.
- Tailwind CSS (bundled with the kit). It is enough for a clean, consistent UI at this size.
- Flash messages for user feedback.
- Pest for testing: Laravel's default, concise syntax.
Most of the structure is generated by Laravel; I only decided where my own files go. Full tree and routes: docs/file-structure.md.
Preparation (before the 2-hour clock): environment setup (Steps 1–4), the project AGENTS.md, and an empty skeleton with every class and method from docs/file-structure.md but no logic. The skeleton commit is the last commit before the clock; everything after it is work within the 2 hours.
Within the 2 hours:
| # | Task | Who |
|---|---|---|
| 1 | Migrations, Note / Attachment models, relationships, factories |
Claude proposes in chunks, I review and add |
| 2 | Notes: NotePolicy, NoteRequest, NoteController, views, flash messages |
Claude proposes, I add |
| 3 | Attachments: config/attachments.php, AttachmentRequest, AttachmentService, controller, upload form |
Claude proposes, I add |
| ∥ | Feature tests, written in parallel from the spec, not from my code | Codex |
| 4 | Run the tests and fix failures; security review of the finished code | Codex reviews, I decide what gets fixed |
| 5 | README, AI_COLLABORATION.md, final commit |
Me |
Why Codex writes the tests in parallel: the tests come from the requirements and not from the implementation, so they check what the app should do rather than repeating what I wrote. If Codex and Claude disagree, I decide.
If time runs short, I cut from the bottom: styling polish first, then UI niceties. Authorization and its tests are never cut.
Commits: setup + skeleton → models → notes → attachments → tests → review fixes → docs.
Before the clock, every file from docs/file-structure.md was created with its classes, method signatures and docblocks, but no logic: models, migrations, config, policy, form requests, service, controllers, routes, views and test files. Method bodies were // TODO comments; the tests were Pest ->todo() entries taken from the spec.
It was built in 5 small batches. For each batch, Claude Code presented the code, I reviewed it, and then either typed it in myself (batch 1) or gave explicit permission to write exactly the reviewed code (batches 2–5).
Why a skeleton first:
- the structure, names and responsibilities are fixed and reviewed before any logic exists;
- route names and config keys are settled, so Codex can write the tests in parallel against the same names;
php artisan route:listand the test run (27 passed, 19 todos) proved the wiring before implementation.
The skeleton commit is the last commit before the 2-hour clock.
Same routine as the skeleton: the agent presents a step, I review it, then I type it or grant permission to write exactly that. One commit per step.
| # | Step | Status |
|---|---|---|
| 1 | Migrations, models, factories | ✅ migrated on PostgreSQL; relationships checked in tinker |
| 2 | Notes: policy, request, controller, views, flash, sidebar link | ✅ (+ one fix, see below) |
| ∥ | Codex: tests written from the spec in parallel | ✅ 54 tests, all passing |
| 3 | Attachments: request, service, controller, upload form | ✅ real upload/download/delete verified on RustFS |
| 3b | UX fixes after my own browser check: attach files on the note form, kit placeholders and misleading links removed | ✅ |
| 4 | Tests green, smoke test on the real stack, Codex security review + 2 fixes | ✅ 77 passed; no high-severity findings |
| 5 | Docs | ✅ |
| 6 | Gap check against the brief: README setup, missing-file download, error pages | ✅ |
Caught by Codex's tests: a non-owner sending an invalid edit got a validation error instead of 404, because Laravel validates the FormRequest before the controller's policy check. That revealed the note exists. Fixed: the FormRequest now runs the policy before validation. Details in AI_COLLABORATION.md.



