PFMIS is a multi-module system for running a poultry operation — flocks, daily production, eggs, feed, health, vaccination, inventory, sales, expenses, HR, and payroll — with role-based access and per-farm data isolation.
It is delivered as two independently deployable applications backed by one relational database:
This document describes the runtime architecture, the internal structure of each application, the data model, and the security and design decisions that shape them. Deployment steps and end-user instructions live in their own documents (Deployment Guide, User Manual).
A classic three-tier architecture. The browser talks only to the API over HTTPS; the API is the sole thing that touches the database.
Authorization: Bearer <token> · CORS (exact origin)public/ only./healthz (liveness), /farms/{id}/logo/file (branding image), and /verify/sale/{token} (QR invoice verification, keyed on an unguessable token). Everything else requires a valid session.| Principle | What it means in this codebase |
|---|---|
| Dependency-free backend | The API uses only the PHP standard library and PDO — no framework, no Composer. It runs on any standard PHP 8 host with nothing to install, which suits low-cost shared hosting. |
| Offline-first frontend | Every data context falls back to browser storage if the API is unreachable, so a network blip never hard-crashes the UI. With no API configured at all, the app runs standalone on seed data. |
| Derive, don't cache | Live figures (current live birds, egg stock) are never stored counters — they are always computed from their source rows, so they can't drift out of sync. |
| Tenant isolation from the session | Which farm a request may touch is decided solely by the logged-in user's farm_id, never by a client-supplied parameter. |
| Convention over configuration | A generic base Controller gives every module a consistent, farm-scoped CRUD surface from a few declared fields; only real business rules are hand-written. |
| Layer | Technology | Notes |
|---|---|---|
| Frontend framework | React 19 + Vite 6 | Static build; no state-management library — React Context only. |
| Frontend libraries | lucide-react, recharts, jspdf, qrcode | Icons, charts, and fully client-side PDF documents with QR codes. |
| Styling | inline styles + theme tokens | No CSS framework; a light/dark token module drives all colours. |
| Backend runtime | PHP 8 | No framework, no Composer dependencies. |
| Data access | PDO (prepared statements) | Singleton connection; ERRMODE_EXCEPTION, emulated prepares off. |
| Database | MariaDB 10.4+ / MySQL 8 | InnoDB, foreign keys, one union view (v_expenses_all). |
| Hosting | Vercel · cPanel/Apache · MySQL | Frontend / backend / database respectively. |
| CI/CD | Vercel Git · GitHub Actions (FTPS) | Push to main auto-deploys both halves. |
A React SPA organised around one context provider per resource. There is no Redux/Zustand — React Context plus a small synchronising hook is the entire state layer.
lib/api.js) — a single apiFetch wrapper attaches the bearer token, parses JSON, and raises on non-2xx. A 401 from any call triggers a clean logout. Typed api.<resource> helpers wrap each endpoint.lib/hooks.js → useSyncedResource) — loads a collection from the API, diffs local edits back to it, and transparently falls back to localStorage when the API is absent or failing.context/*Context.jsx) — one provider per domain (Flocks, Sales, Eggs, Feed, …) exposing rows plus actions. Security-sensitive or server-validated writes (auth, egg/bird usage) use explicit async calls rather than the optimistic diff hook, so a server rejection surfaces as a real error.pages/*Page.jsx, components/*) — presentation only; they read from contexts and call their actions.app/App.jsx) — an AuthProvider/Gate mounts the data providers only after a session exists. Routing is pathname-based (history.pushState + a match on location.pathname); no router library.After login, Gate mounts a nested stack of providers around the app shell, so every page shares one loaded copy of each collection:
AuthProvider → Gate
FarmsProvider → DailyEntries → Payroll → Feed → Flocks → Employees
→ Health → Vaccination → Inventory → Sales → Expenses
→ Eggs → EggUsage → BirdUsage → Confirm → Lists → PaymentAccounts
→ AppShell (sidebar · top bar · routed page)
Three thin layers. Each request passes top-to-bottom; no SQL is ever built from string concatenation of input.
public/index.php — front controllerController (base + one per module)mapIn/mapOut), enforces farm ownership, and provides generic index/show/create/update/destroy. Modules override only for real business rules.Database — PDO singletonall / one / run helpers, transactions, prepared statements only.src/Core)| Class | Responsibility |
|---|---|
| Router | Minimal method+path matcher; compiles /flocks/{id} to a regex with named captures. First match wins, in registration order. |
| Controller | Generic farm-scoped CRUD over one table from declared $table/$fields; the base class for every module. |
| Auth | Bearer-token sessions, password hashing, permission checks, the currentFarmId() source of truth, and DB-backed rate limiting. |
| Database | PDO singleton and query helpers. |
| Http | Request-body parsing, JSON responses, and translation of raw DB errors into safe, human messages. |
Routes are registered in index.php with three composable helpers:
$authOnly(fn) — requires any valid session (e.g. the farm switcher, /auth/me).$guard(module, level, fn) — requires at least view or edit permission on a module; this is the actual role enforcement.$crud(path, Controller, module) — binds the five standard CRUD routes for a resource, each gated by the module's guard.Specialised controllers add hand-written logic on top of the generic surface — for example SalesController (header + line items + auto-linked egg stock, in a transaction), DailyEntryController (a FOR UPDATE feed-stock draw), PayrollController (computed runs), and BackupController (SQL export).
A typical authenticated write — e.g. POST /sales:
| # | Step |
|---|---|
| 1 | Browser sends the request with Authorization: Bearer <token> and a JSON body. A CORS pre-flight may precede it. |
| 2 | Apache routes everything to index.php via .htaccess, which also re-exposes the Authorization header to PHP. |
| 3 | index.php emits CORS/security headers, answers OPTIONS, and matches the route. |
| 4 | The route's $guard calls Auth::requirePermission('sales','edit') — resolving the session, the user, and their role permissions, or halting with 401/403. |
| 5 | The controller validates input, stamps farm_id from currentFarmId(), and runs the work in a DB transaction using prepared statements. |
| 6 | The result is returned as JSON. Unexpected faults are caught by the global handler, logged, and returned as a generic 500 (no internals leak). |
| 7 | The frontend context updates its rows; a 401 anywhere forces a clean logout. |
One tenant root — farms — with every operational record carrying a farm_id foreign key. Deleting a farm cascades to all of its data. A separate cluster holds authentication.
| Parent | Children | Meaning |
|---|---|---|
| sales | sale_items, egg_usage | An invoice's line items; egg sales auto-create a linked stock-draw row. |
| daily_entries | daily_entry_feed | A day's production plus its per-type feed consumption lines. |
| payroll_runs | payroll_run_lines → payroll_run_line_items | A run, a line per employee, and each line's components. |
expenses with payroll runs and feed purchases, so Expenses shows a complete picture without duplicating rows — a source column flags which rows are derived and read-only.Authentication uses opaque bearer tokens stored server-side in the sessions table (not stateless JWTs), so logout is a real delete and revocation is immediate. A token is a 64-char random string with a 30-day server-computed expiry. On each request Auth::currentUser() joins sessions → users → roles to resolve the caller, their farm, and their permissions in one query; suspended users are rejected.
Each role stores a JSON permission map of module → none | view | edit. Guards compare the caller's level against the route's requirement. The frontend reads the same map to hide modules a role can't view — but enforcement is always server-side.
A DB-backed fixed-window limiter (rate_limits table, Auth::rateLimit()) throttles authentication: login is capped per IP+email, and password-reset requests per IP, returning 429 past the cap. No external cache is required.
| Concern | Control |
|---|---|
| Tenant isolation | Auth::currentFarmId() is the only source of the farm scope; every farm-scoped query derives it from the session. Cross-farm reads 404; cross-farm writes are rejected. |
| SQL injection | Prepared statements everywhere, emulation off. No input is concatenated into SQL — injection is impossible by construction. |
| Passwords | bcrypt (password_hash). Backups deliberately exclude all credential tables, so an export can never contain a hash. |
| CSRF | Not applicable — auth is a bearer header, not a cookie, so there is no ambient credential to forge. |
| CORS | A single exact allowed origin (the frontend), configured per environment — never * in production. |
| Public invoice verify | Keyed on a random verify_token, never the sequential id, so the endpoint can't be walked to harvest other farms' customers or amounts. |
| Password reset | The reset token is never returned in the API response (only ever delivered by email once a mailer exists); until then, admins reset passwords in-app. |
| Error handling | DB errors are translated to safe messages; unexpected 5xx faults are logged server-side and returned generically. display_errors is off; X-Content-Type-Options: nosniff is sent. |
| File layout | Only public/ is web-reachable, so config.php (DB password) and all source stay off the public web. |
Current live birds and egg stock are computed on read, never stored:
live birds = SUM(flocks.qty) − SUM(daily_entries.mortality + culls) − birds sold (derived from sale_items) − SUM(bird_usage.qty) // donated / consumed / other egg stock = SUM(egg_collections.total) − SUM(egg_usage.qty)
egg_usage and bird_usage record only the manual ways stock leaves (donated/consumed/other). Deaths come from daily entries and sales are derived from real invoice lines — never re-entered, so a fact is never counted twice.
Since sale items are free text from a managed list, the count of birds a sale represents is inferred by keyword match — the same heuristic implemented in the frontend (metrics.js) and the backend (BirdUsageController), kept deliberately in sync.
Renaming a dropdown value cascades the new label to every real record that used it (per a table of affected columns), so a rename never orphans data; deletion is blocked while a value is still in use.
Numbers upserts one production row per house/day; Feed runs a transaction that locks each feed-stock row (FOR UPDATE), verifies availability, deducts at the real unit cost, and rolls totals into the entry — so stock can never go negative.
| Half | Host | Pipeline |
|---|---|---|
| Frontend | Vercel (static) | Git integration — push to main triggers a build. VITE_API_URL points it at the API; a vercel.json rewrite serves index.html for all routes (SPA deep-links). |
| Backend | cPanel / Apache | GitHub Actions deploys over FTPS on push, excluding config.php and uploads. Document root is the repo's public/; .htaccess routes to index.php and forwards the auth header. |
| Database | cPanel MySQL | Schema and any migrations are applied through phpMyAdmin. Auto-deploy never touches the database. |
Configuration is environment-driven: config.php (git-ignored, above the web root) supplies DB credentials and the exact CORS origin; environment variables override the file where available.
settings:edit) produces a restorable .sql of a farm's data, in foreign-key-safe order, excluding credentials.| Limitation | Direction |
|---|---|
One login maps to exactly one farm (users.farm_id). A second farm created by an account is currently unreachable by it. | Introduce a users↔farms join table to let one login manage several farms, scoped correctly. |
managed_lists are shared, not farm-scoped. | Add farm_id to managed lists for true per-tenant configuration. |
| No email delivery yet — self-service password reset and notifications can't complete. | Wire an SMTP/transactional mailer; the reset flow already issues tokens, ready to send. |
| Single-tenant-per-deployment operationally, though the data model is multi-tenant. | For SaaS: self-service signup, billing, per-tenant rate limits, and the isolation work above. |