Technical Reference

PFMIS
System Architecture

How the Poultry Farm Management Information System is built — its tiers, components, data model, security model, and the design decisions behind them. For engineers, reviewers, and technical stakeholders.
SystemPFMIS — Poultry Farm Management Information System
StyleThree-tier · dependency-free PHP API · offline-first React SPA
Repospfmis-web (frontend) · pfmis-backend (API + schema)
AudienceEngineers & technical reviewers

Contents

  1. Introduction & scope
  2. Architecture at a glance
  3. Guiding principles
  4. Technology stack
  5. Frontend architecture
  6. Backend architecture
  7. Request lifecycle
  8. Data model
  9. Authentication & authorization
  10. Security architecture
  11. Key domain patterns
  12. Deployment & delivery
  13. Cross-cutting concerns
  14. Known limitations & roadmap

1 Introduction & scope

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).

2 Architecture at a glance

A classic three-tier architecture. The browser talks only to the API over HTTPS; the API is the sole thing that touches the database.

Client — web browsers & phones
Any modern browser. The app is responsive; no install.

HTTPS · static assets
Presentation — React SPA (Vercel)
Vite static build · offline-first · client-side routing · renders PDFs (invoices/receipts/reports) in the browser.
farms.houseofloveafrica.org

HTTPS · JSON · Authorization: Bearer <token> · CORS (exact origin)
Application — PHP 8 REST API (cPanel / Apache)
Front controller → Router → Controller → PDO. No framework, no Composer packages. Document root is public/ only.
api.houseofloveafrica.org

PDO · prepared statements only
Data — MariaDB / MySQL
InnoDB, foreign keys, one read-time view. Managed through phpMyAdmin.
Two public, unauthenticated paths A handful of API routes are intentionally public: /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.

3 Guiding principles

PrincipleWhat it means in this codebase
Dependency-free backendThe 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 frontendEvery 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 cacheLive 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 sessionWhich farm a request may touch is decided solely by the logged-in user's farm_id, never by a client-supplied parameter.
Convention over configurationA generic base Controller gives every module a consistent, farm-scoped CRUD surface from a few declared fields; only real business rules are hand-written.

4 Technology stack

LayerTechnologyNotes
Frontend frameworkReact 19 + Vite 6Static build; no state-management library — React Context only.
Frontend librarieslucide-react, recharts, jspdf, qrcodeIcons, charts, and fully client-side PDF documents with QR codes.
Stylinginline styles + theme tokensNo CSS framework; a light/dark token module drives all colours.
Backend runtimePHP 8No framework, no Composer dependencies.
Data accessPDO (prepared statements)Singleton connection; ERRMODE_EXCEPTION, emulated prepares off.
DatabaseMariaDB 10.4+ / MySQL 8InnoDB, foreign keys, one union view (v_expenses_all).
HostingVercel · cPanel/Apache · MySQLFrontend / backend / database respectively.
CI/CDVercel Git · GitHub Actions (FTPS)Push to main auto-deploys both halves.

5 Frontend architecture

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.

5.1  Layers

5.2  Provider tree

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)
Offline-first, concretely Because every context degrades to storage, the built product also works as a standalone demo with no backend at all — the same code path that protects a live deployment from a flaky network.

6 Backend architecture

Three thin layers. Each request passes top-to-bottom; no SQL is ever built from string concatenation of input.

public/index.php — front controller
CORS & security headers · PSR-4-ish autoloader · route table · permission guards · global error handler. The one public entry point.
Controller (base + one per module)
Translates JSON ↔ DB columns (mapIn/mapOut), enforces farm ownership, and provides generic index/show/create/update/destroy. Modules override only for real business rules.
Database — PDO singleton
all / one / run helpers, transactions, prepared statements only.

6.1  Core services (src/Core)

ClassResponsibility
RouterMinimal method+path matcher; compiles /flocks/{id} to a regex with named captures. First match wins, in registration order.
ControllerGeneric farm-scoped CRUD over one table from declared $table/$fields; the base class for every module.
AuthBearer-token sessions, password hashing, permission checks, the currentFarmId() source of truth, and DB-backed rate limiting.
DatabasePDO singleton and query helpers.
HttpRequest-body parsing, JSON responses, and translation of raw DB errors into safe, human messages.

6.2  Routing & guards

Routes are registered in index.php with three composable helpers:

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).

7 Request lifecycle

A typical authenticated write — e.g. POST /sales:

#Step
1Browser sends the request with Authorization: Bearer <token> and a JSON body. A CORS pre-flight may precede it.
2Apache routes everything to index.php via .htaccess, which also re-exposes the Authorization header to PHP.
3index.php emits CORS/security headers, answers OPTIONS, and matches the route.
4The route's $guard calls Auth::requirePermission('sales','edit') — resolving the session, the user, and their role permissions, or halting with 401/403.
5The controller validates input, stamps farm_id from currentFarmId(), and runs the work in a DB transaction using prepared statements.
6The result is returned as JSON. Unexpected faults are caught by the global handler, logged, and returned as a generic 500 (no internals leak).
7The frontend context updates its rows; a 401 anywhere forces a clean logout.

8 Data model

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.

Tenant root
farms
Production
flocksdaily_entriesdaily_entry_feed egg_collectionsegg_usagebird_usage feed_stockfeed_purchases
Health
health_eventsvaccinations
Operations & finance
inventory_itemssalessale_items expensesfarm_payment_accounts
HR & payroll
employeespayroll_runspayroll_run_linespayroll_run_line_items
Platform & auth
usersrolessessionspassword_resets managed_listsrate_limits

Composite relationships

ParentChildrenMeaning
salessale_items, egg_usageAn invoice's line items; egg sales auto-create a linked stock-draw row.
daily_entriesdaily_entry_feedA day's production plus its per-type feed consumption lines.
payroll_runspayroll_run_lines → payroll_run_line_itemsA run, a line per employee, and each line's components.
v_expenses_all A read-time SQL view unions manual 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.

9 Authentication & authorization

9.1  Sessions

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.

9.2  Roles & permissions

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.

9.3  Rate limiting

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.

10 Security architecture

ConcernControl
Tenant isolationAuth::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 injectionPrepared statements everywhere, emulation off. No input is concatenated into SQL — injection is impossible by construction.
Passwordsbcrypt (password_hash). Backups deliberately exclude all credential tables, so an export can never contain a hash.
CSRFNot applicable — auth is a bearer header, not a cookie, so there is no ambient credential to forge.
CORSA single exact allowed origin (the frontend), configured per environment — never * in production.
Public invoice verifyKeyed on a random verify_token, never the sequential id, so the endpoint can't be walked to harvest other farms' customers or amounts.
Password resetThe 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 handlingDB 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 layoutOnly public/ is web-reachable, so config.php (DB password) and all source stay off the public web.

11 Key domain patterns

Derive, don't cache

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)

Usage ledgers

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.

Bird-sold heuristic (mirrored)

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.

Managed-list cascade

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.

Two write paths on daily entries

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.

12 Deployment & delivery

HalfHostPipeline
FrontendVercel (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).
BackendcPanel / ApacheGitHub 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.
DatabasecPanel MySQLSchema 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.

13 Cross-cutting concerns

14 Known limitations & roadmap

LimitationDirection
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.
Note The hard part of multi-tenancy — strict per-farm data isolation enforced server-side — is already in place. The roadmap items are additive, not rewrites.