Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

37 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

OpenHIMS2 β€” Open Health Information Management System

A free, open-source Health Information Management System built with Laravel 10 and MySQL, designed for multi-clinic hospital networks.

Four things set it apart, and this README is organised around them:

Pillar In one line
πŸ”— Interoperability A read-only FHIR R4 API any other HIS can pull from β€” HHIMS, OpenMRS, anything
🏷 Standards Coding Drugs to RxNorm, diagnoses to ICD-10-CM, investigations to LOINC β€” so records mean the same thing outside this system
🧩 Module System Every clinic type beyond the core is an installable package you add and remove from the admin UI
🧱 Component System Clinical blocks exist once and are called by short code, not copy-pasted into each view

πŸ–Ό Screenshot: Clinical dashboard Β· docs/images/hero-dashboard.png


About the image placeholders. Every πŸ–Ό Screenshot: line below marks a reserved spot. Drop the image at the suggested path and replace the line with ![Caption](docs/images/name.png). No images are bundled with this repository.


Contents

  1. Requirements
  2. Installation
  3. First Run
  4. πŸ”— Interoperability β€” the FHIR API
  5. 🏷 Standards Coding
  6. 🧩 The Module System
  7. 🧱 The Component System
  8. Patient Registration
  9. Admin Setup
  10. Daily Clinical Use
  11. Reports
  12. Database Schema
  13. Testing
  14. Troubleshooting
  15. License

Requirements

Version
PHP 8.1+
Laravel 10.x
MySQL / MariaDB 10.4+
Composer 2.x
Node.js 16+ (for npm install only β€” there is no Vite build)

Recommended: XAMPP (Apache + MySQL + PHP bundled).

All CSS and JS are served from local files in public/vendor/. Nothing is fetched from a CDN at runtime, so the system works on an air-gapped hospital network.


Installation

# 1. Clone and install
git clone https://github.com/madusankabibile/OpenHIMS2-core.git
cd OpenHIMS2-core
composer install

# 2. Frontend assets (copied to public/vendor/ β€” no build step)
npm install bootstrap bootstrap-icons
php artisan app:publish-assets

# 3. Environment
cp .env.example .env
php artisan key:generate

# 4. Point .env at your database
#    DB_DATABASE=openhims2
#    DB_USERNAME=root
#    DB_PASSWORD=
#    APP_URL=http://localhost/OpenHIMS2-core

# 5. Migrate and seed the CORE (admin user, GMC template, terminology, components)
php artisan migrate:fresh --seed

# 6. Install whichever modules this deployment needs
php artisan module:install gp --enable
php artisan module:install ortho --enable      # dc / gi / office likewise

# 7. Mirror the RxNorm concept list (needed before drugs can be coded)
php artisan rxnorm:sync

# 8. Serve
php artisan serve                              # β†’ http://127.0.0.1:8000

Using XAMPP

Place the project in htdocs/ and browse to http://localhost/OpenHIMS2-core β€” no /public in the URL. A root .htaccess rewrites into public/, and public/index.php corrects the base path Apache reports. Old /public/... links are permanently redirected. Only public/ is web-exposed; .env, vendor/ and storage/ stay unreachable.

Set APP_URL to match the folder name exactly. A mismatch does not break browsing β€” you type the real path β€” but every absolute URL Laravel generates will be wrong, including redirects.

Importing database.sql

database.sql carries the full schema plus demo data.

mysql -u root -p -e "CREATE DATABASE openhims2 CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
mysql -u root -p openhims2 < database.sql
php artisan db:seed --class=AdminSeeder

First Run

Log in at /:

Email:    admin@phims.lk
Password: password

Change this password immediately via Admin β†’ Profile.

A fresh deployment starts with no modules installed β€” only the General Medical Clinic core. Go to Admin β†’ Modules to add the rest.

πŸ–Ό Screenshot: Login screen Β· docs/images/login.png


πŸ”— Interoperability β€” the FHIR API

Other systems read this one over FHIR R4 at /fhir/*. This is what the coding exists for: RxNorm, ICD-10-CM and LOINC codes ride out on the resources, so a receiving system understands the record without a translation table.

πŸ–Ό Screenshot: Admin β†’ Interoperability Β· docs/images/interoperability.png

Read-only, deliberately

The API serves read and search-type. It does not accept writes.

Accepting records would need patient-matching and merge rules that do not exist here, and a half-built write path is worse than none β€” it invites an integrator to depend on behaviour nobody has specified. The CapabilityStatement says exactly this, so an integrator discovers the limit rather than finding it by trying.

config/fhir.php is the single source of truth

The CapabilityStatement at /fhir/metadata, the endpoint reference in the admin UI, and the scope picker on the key-issuing form are all generated from that one file. They cannot drift apart β€” which is how integration documentation usually becomes a lie.

Resources served

Resource Scope Backed by Notes
Patient patient.read patients Search: identifier, name, gender, birthdate. Plus $everything
Encounter encounter.read clinic_visits Always ambulatory (AMB)
Condition condition.read visit_notes Complaints + past history, ICD-10-CM where mapped. Requires patient
Observation observation.read BP / investigations / visits Vitals with static LOINC, results LOINC where mapped. Requires patient
MedicationRequest medicationrequest.read visit_drugs RxNorm where mapped
AllergyIntolerance allergyintolerance.read patient_allergies Allergen is free text and is not claimed to be coded
Procedure procedure.read surgery_cases Arthroplasty register. Served only while the ORTHO module is enabled
Organization organization.read institutions partOf carries the parent institution
Medication medication.read drug_names Search by code to resolve an RxNorm code back to a local drug

Conditions and Observations are derived from visit notes, not stored as rows. That is why their searches require a patient parameter (400 otherwise) and their ids are derived ({noteId}-{category}-{index}).

Issuing an API key

Admin β†’ Interoperability β†’ New Key. Pick the scopes, one per resource.

Keys are hashed with SHA-256 and never stored. The plaintext is shown once, at creation. key_prefix is kept in clear so a key stays identifiable in the UI without being reconstructable from what the database holds.

πŸ–Ό Screenshot: Issuing an API key, plaintext shown once Β· docs/images/api-key-new.png

Calling it

# 1. Discover what this server supports β€” no key required
curl http://localhost/OpenHIMS2-core/fhir/metadata

# 2. Find a patient by NIC
curl -H "Authorization: Bearer ohims_ab12cd34_xxxxx…" \
     "http://localhost/OpenHIMS2-core/fhir/Patient?identifier=199012345678"

# 3. Pull that patient's ENTIRE record in one call
curl -H "Authorization: Bearer ohims_ab12cd34_xxxxx…" \
     "http://localhost/OpenHIMS2-core/fhir/Patient/42/\$everything"

$everything is the call integrators actually want. It returns the patient plus every encounter, condition, observation, prescription, allergy and procedure in one Bundle. Without it, syncing one patient means six separate searches.

Errors are always FHIR

Never an HTML error page:

Status Meaning
401 Missing, bad, revoked or expired key
403 Key lacks the scope for that resource
404 Unknown id
400 Required parameter missing (e.g. patient on Condition)
429 Throttled β€” 300 requests/minute per key

Every one is an OperationOutcome with a severity, a code and a human diagnostics line.

Connecting another system

OpenMRS β€” use the FHIR Squared module and point it at /fhir with a key holding patient.read, encounter.read, condition.read and observation.read. Match patients on NIC (identifier), which is the identifier both systems will hold.

HHIMS β€” poll Patient?identifier={NIC}, then $everything on the returned id. Codes come through as ICD-10-CM and LOINC, so no local mapping table is needed.

Anything else β€” read /fhir/metadata first. It lists every resource, search parameter and operation this server actually supports, generated from configuration rather than written by hand.

The other API: between modules

Separate from FHIR, modules talk to each other over a named, scoped internal API β€” the only way clinical data crosses a module boundary.

// The GMC doctor's stock badge, asking the pharmacy module
$stock = ModuleApi::callSoft('gp', 'stock.availability', [
    'drug'           => 'Metformin',
    'institution_id' => $institutionId,
]);

Core offers patients.get, patients.search, visits.queue, visits.search, visits.get, visits.batch, visits.mark-dispensed, drugs.search, drugs.defaults, units.view and units.views-for-institution. A module declares its own in its manifest.

The same endpoints are served over HTTP at /api/modules/{module}/{endpoint} behind API-key auth with per-endpoint scopes. In-app calls run in-process by default (HTTP self-calls would deadlock single-worker XAMPP); set MODULES_API_DRIVER=http to send them over the wire instead. Same endpoints, same payloads, either way.

Everything fails soft. callSoft() returns null when the module is absent, disabled or erroring. An absent pharmacy must never stop a doctor prescribing.


🏷 Standards Coding

Local concepts carry standard codes so a record means the same thing outside this system.

πŸ–Ό Screenshot: Admin β†’ Terminology, a coded box Β· docs/images/terminology-coded.png

One mapping table, not a column per system

concept_mappings (mappable_type, mappable_id, system, code, display, is_primary)
                 ↑ polymorphic β€” one row = one FHIR Coding

drug_names and terminology_terms share it, and one concept can hold several codings. The HasConceptMappings trait provides addMapping(), isCoded() and toCodeableConcept() β€” which emits a FHIR CodeableConcept directly. System URIs live in config/terminology.php, not at the export site.

A column per system would need a migration for every new vocabulary, and would still only hold one code each.

RxNorm is mirrored; ICD-10 and LOINC are queried live

RxNorm β€” RxNav has no ingredient-level prefix search, but hands over the whole concept list in one request. So it is mirrored into rxnorm_concepts (php artisan rxnorm:sync, ~23,000 concepts) and searched in-DB. Drug entry then survives an internet outage.

ICD-10-CM and LOINC β€” the NLM Clinical Table API is built for type-ahead, so it is called live and cached.

Neither service needs an API key. (WHO's ICD-11 API would need OAuth credentials; ICD-10-CM via NLM avoids that entirely.)

Which box uses which system

~380 coded terms ship seeded. Every code was verified against the NLM Clinical Table Search Service before being written down.

Box Code system Terms
Presenting Complaints ICD-10-CM 54
Past Medical History ICD-10-CM 58
Working + Differential Diagnosis ICD-10-CM 55 each
Menstrual History ICD-10-CM 19
Past Surgical History ICD-9-CM procedures 40
Investigations LOINC 48
6 examination findings boxes ICD-10-CM 83 total
Durations, instructions, social history (free text β€” no code system fits) 74

The term is clinic wording; the mapping display is the official name. "Fits" is stored under R56.9 Unspecified convulsions. The clinician keeps their vocabulary; the export carries the standard's.

Codes match what the box records. A complaint is a symptom (fever is R50.9, never A90 Dengue), a diagnosis is a disease, an investigation is a question (LOINC, not ICD).

Why the odd systems

Past Surgical History uses ICD-9-CM Volume 3. ICD-10-CM has no procedure codes at all β€” its nearest offer is a status code ("acquired absence of gallbladder"), which records the aftermath and loses the operation. ICD-10-PCS has no free lookup service, and its codes fix the operative approach and device, which a history line ("Appendicectomy, 2015") simply does not know. ICD-9-CM Volume 3 is published, freely searchable, and sits at exactly the granularity a history is written at.

Uncoded is a real state, and ~110 terms are in it

ICD-10-CM classifies disease: it codes abnormality and is silent on normality. So "Hepatomegaly" is R16.0 while "Soft, non-tender abdomen" stays uncoded.

Findings whose only ICD-10-CM home is a wastebasket code are left uncoded too β€” crepitations, reduced air entry and a dull percussion note all collapse into R09.89. Exporting three different findings under one code meaning "something respiratory" looks like meaning and carries none.

SNOMED CT is where physical findings belong; when it is licensed here, the boxes can be rebound without touching a single term.

Coding is never mandatory

A drug or term always saves. If no code is picked it is flagged uncoded and can be mapped later from the list. NlmLookup returns [] on failure rather than throwing β€” a terminology server being down must not stop an admin working.

On the way out, an uncoded concept returns text with no coding. That is valid FHIR and honest: it says this is what was written; nobody has said what it means. Never fabricate a code.


🧩 The Module System

The GMC core plus installable modules. The General Medical Clinic β€” patients, queue, visit notes, prescribing, terminology, FHIR API β€” is the always-present core. Every other unit type is a module living in modules/<CODE>/ and installed from Admin β†’ Modules.

πŸ–Ό Screenshot: Admin β†’ Modules, the card grid Β· docs/images/modules-index.png

Modules that ship

Code Name Capabilities
GP General Pharmacy queue, pharmacy_dispense
DC Dental Clinic queue, prescribing
GI General Inward queue, prescribing, bp_trends, inv_charts
ORTHO Orthopedic Surgery Register surgical_register
OFFICE Office (none β€” admin/staff)

Package layout

modules/<CODE>/
  module.json                ← manifest: unit template, capabilities, view templates,
                               copies map, seeders, API endpoints, terminology boxes,
                               reports, FHIR mappers, components
  database/migrations/       β†’ database/migrations/modules/<code>/
  database/seeders/          β†’ database/seeders/Modules/<CODE>/
  resources/views/           β†’ resources/views/clinical/<code>/
  components/                β†’ resources/views/components/ohims/
  routes/web.php             β†’ routes/modules/<code>.php
  src/                       β†’ app/Modules/<CODE>/   (ns App\Modules\<CODE>)

src/ lands under app/, which the existing PSR-4 App\ map already covers β€” so installing a module needs no composer step.

Install, enable, disable, uninstall

Install copies files β†’ runs migrate --path β†’ registers templates and terminology β†’ runs seeders β†’ issues a type=module API key (plaintext shown once).

A fresh install lands switched off. Activation is a separate Enable click β€” so an admin reviews what arrived before it reaches clinicians. (module:install <code> --enable to do both.) Installs are idempotent; a forced re-install keeps the current on/off state.

Disable is a status flip. Files, tables and data all stay. The module vanishes from the launcher, from unit and view creation, from clinical dispatch (a "module disabled" page renders instead), from its routes and from its API endpoints.

Uninstall fully reverses the install: migrate:reset drops the module's tables (data is destroyed), every copied file is deleted, its templates, terminology, service key and registry row are removed, and every unit built on its template is deleted too β€” its views, assignments and visits follow through the core's foreign-key cascades. Patients always stay. The package remains on disk, so Install rebuilds it fresh.

πŸ–Ό Screenshot: Module detail β€” full source before install Β· docs/images/module-detail.png

Before you install, you can read what it will do. A module's detail page renders the package's complete source β€” migrations, seeders, blades, routes, PHP β€” in code tabs, straight from disk. Nothing installs from a package you cannot inspect first.

Capabilities, not code checks

$unit->unitTemplate->hasCapability('prescribing')

unit_templates.capabilities carries queue, prescribing, bp_trends, inv_charts, pharmacy_dispense, surgical_register. This replaces code === 'GMC' branching entirely β€” a new module declares what it can do instead of the core learning its name.

DC and GI are views-only modules that reuse the core clinical controllers: capability consumption, not data access.

Rules that matter

  • Module tables reference core rows as plain ints β€” never FK constraints across the module/core boundary. A constraint would make uninstall impossible.
  • route:cache must not be used. Module routes are database-conditional; the installer runs route:clear after every change.
  • Copy targets are gitignored generated artifacts. Edit modules/<CODE>/ and re-install β€” never the copies.

CLI

php artisan module:install <code> [--enable]
php artisan module:uninstall <code>

🧱 The Component System

A component is a clinical block a view calls instead of reimplementing.

<x-ohims.blood-pressure :visit="$visit" :unit-view="$unitView" :prev-visits="$prevVisits" />

Browse them at Admin β†’ Components. Full guide: docs/COMPONENTS.md.

πŸ–Ό Screenshot: Admin β†’ Components, the card grid Β· docs/images/components-index.png

Why

Before this, the same blood-pressure table was written out in the doctor's visit page and again in the patient profile; the same tag input was copy-pasted eleven times with only the category changed; and a module wanting a drug chart had no option but a twelfth copy.

Copies drift. A fix applied to one is a fix missing from the others, and nothing in the code says which copies exist. Extracting them took the GMC doctor visit page from 4,046 lines to about 670 β€” what is left is the patient header, the allergy list and the End Visit action.

The six

Short code Tag What it is
PT-REG <x-ohims.patient-registration /> Personal details, DOB/age with the under-16 guardian branch, NIC/mobile duplicate check
BP-VITAL <x-ohims.blood-pressure /> BP capture + Chart/Table toggle across this and previous visits
INV-LAB <x-ohims.investigations /> Results grouped by test, each with its own chart
DRG-CLINIC <x-ohims.clinic-drugs /> Standing clinic chart + change list + visit summary
DRG-MGMT <x-ohims.management-drugs /> This visit's prescription + live pharmacy availability
TERM-BOX <x-ohims.terminology-input /> Tag input bound to one terminology category, auto-saving

They form a dependency graph, enforced by the catalogue:

PT-REG ──┬── BP-VITAL
         β”œβ”€β”€ INV-LAB
         β”œβ”€β”€ TERM-BOX
         └── DRG-CLINIC ── DRG-MGMT

Everything depends on PT-REG because everything records against a patient. A component cannot be disabled while an enabled component depends on it.

Where a component ends: slots

PT-REG answers who the patient is β€” the same question in every unit, which is what makes it shareable. It does not answer why they are here today. Visit category, OPD number and admission vitals describe an attendance, and every unit template means something different by that: a pharmacy has no visit category, a surgical register has no OPD number.

So those fields stay with the caller and pass into the slot, rendering as a second column inside the same <form>:

<x-ohims.patient-registration :unit-view="$unitView" submit-label="Register & Add to Queue">
    ... this unit's own attendance fields ...
</x-ohims.patient-registration>

The rule generalises: shared is what is the same everywhere; anything a unit template would answer differently belongs to the unit. When a showX flag appears to switch a block off for one caller, that block wants to be a slot.

Usage is discovered, not declared

ComponentScanner reads the blades for <x-ohims.* /> tags and rewrites component_usages wholesale. The answer to "where is the blood pressure component used?" has to come from the blades themselves, or it becomes a list that was true once.

πŸ–Ό Screenshot: Component detail β€” props and every call site Β· docs/images/component-detail.png

Each component's page shows a usage table: id Β· module Β· view Β· short code. For TERM-BOX it also shows which input box and which terminology table each call site is bound to, because one view holds many boxes and they are not interchangeable.

Blade comments are stripped before matching, so a tag named in a comment is documentation, not a call site. Module views are read from the package, never from the installer's copies.

php artisan components:sync        # or press "Rescan blades" in the admin UI

The module installer runs this automatically on install, enable, disable and uninstall.

Disabling

A disabled component renders nothing at every call site β€” the tags stay in the blades, nothing is torn out, and enabling puts it all back. This is a switch for deployments that genuinely do not want a block, not a debugging tool.

Shipping a component from a module

Declare it under a components key in module.json, put the blade in modules/<CODE>/components/, and add a copies entry targeting resources/views/components/ohims. The installer registers the catalogue row; the uninstaller removes it and its usage rows. A component owned by a disabled module renders nothing, exactly like one an admin switched off.

Full worked example in docs/COMPONENTS.md.


Patient Registration

patients is the common registration record β€” the person, not the attendance. Every module records against this one row, so a patient registered at the pharmacy is the same patient the clinic sees. Nothing visit-shaped lives here.

Two ways in

Page Route Creates
Pure registration GET /clinical/{unitView}/patients/new The patient only β€” no visit, no queue entry
GMC register + visit GET /clinical/{unitView}/register Patient + today's visit + queue entry + admission BP

Registering someone and admitting them are different acts, and only the GMC's OPD desk does both at once. Any module can link to the pure page:

<a href="{{ route('clinical.patients.new', [$unitView->id, 'return' => '/' . request()->path()]) }}">
    Register a patient
</a>

return accepts a site-relative path only β€” an absolute URL, //host, or a javascript: scheme is refused, or the parameter would be an open redirect wearing the hospital's domain. On success the redirect flashes registered_patient_id so the caller can pick the patient up without searching.

πŸ–Ό Screenshot: Pure patient registration page Β· docs/images/patient-registration.png

It is FHIR R4 Patient, column by column

Form field Column FHIR element
Full Name name Patient.name[0].text
Family / Given Name family_name, given_name Patient.name[0].family / .given[]
Gender gender Patient.gender β€” required binding, incl. unknown
Date of Birth dob Patient.birthDate
PHN / NIC / Passport phn, nic, passport_no Patient.identifier[]
Mobile / Home / Email mobile, phone_home, email Patient.telecom[]
Address + parts address, address_* Patient.address[0] β€” free text to .text, parts to .city .district .postalCode .country
Marital Status marital_status Patient.maritalStatus β€” HL7 v3 MaritalStatus
Preferred Language preferred_language Patient.communication[0].language β€” BCP-47
Guardian guardian_* Patient.contact[0] β€” relationship from HL7 v3 RoleCode
β€” institution_id Patient.managingOrganization

config/patients.php holds those value sets, and both the form's <option> lists and the FHIR mapper read it β€” so an option and the code it exports cannot drift apart.

institution_id, registered_unit_id, registered_by and registered_at are stamped once at creation and never rewritten by a later edit: correcting a spelling from another unit does not move where the patient was registered.

Only name, gender and address are required. The rest sit in a collapsed Additional details disclosure β€” a busy OPD desk should not be slowed by fields it will leave empty, and an uncoded patient is a valid patient.


Admin Setup

Two access modes, split by role:

Mode Prefix Who
Admin /admin role = admin
Clinical /clinical role = user

1. Institutions

Admin β†’ Hierarchy Management. Institutions nest:

National Department of Health
  └─ Northern Regional Authority
       └─ St. George's Hospital        ← leaf: where units live

πŸ–Ό Screenshot: Institution hierarchy tree Β· docs/images/admin-hierarchy.png

2. Units

Admin β†’ Unit Management. A unit is a physical clinic room or ward, typed by a unit template (GMC, or one from an installed module).

πŸ–Ό Screenshot: Unit management Β· docs/images/admin-units.png

3. Unit Views

Admin β†’ View Management. A unit view binds a unit to a role view β€” "GMC Akurana β€” Doctor". This is what a clinical user is actually assigned to.

πŸ–Ό Screenshot: View management Β· docs/images/admin-views.png

4. Users

Admin β†’ User Management. Create the user, pick their institution, assign one or more unit views.

Login routing follows the assignment: an admin lands on the admin dashboard; a user with one view goes straight into it; a user with several picks from a list; a user with none gets an error rather than an empty screen.

πŸ–Ό Screenshot: User management, assigning views Β· docs/images/admin-users.png

5. Drugs

Admin β†’ Drugs. Add drugs and search RxNorm to code them. Defaults (route, dose, unit, frequency) autofill the prescribing form later.

πŸ–Ό Screenshot: Drug management with RxNorm search Β· docs/images/admin-drugs.png

6. Terminology

Admin β†’ Terminology. Every clinical box and its terms. A box bound to a code system offers live ICD-10-CM or LOINC lookup; a box bound to none stays free text β€” and a code posted at such a box is dropped rather than stored, so the binding is never quietly violated.

πŸ–Ό Screenshot: Terminology box with code lookup Β· docs/images/admin-terminology.png


Daily Clinical Use

Clerk

Register patients and manage today's queue. Duplicate NIC or mobile is caught on blur, offering the existing patient and an "add to today's queue" action instead of a second record.

πŸ–Ό Screenshot: Clerk queue view Β· docs/images/clinical-clerk.png

Doctor

Queue β†’ start visit β†’ the visit page: allergies, history and examination (terminology boxes), blood pressure, investigations, the clinic drug chart, and management prescribing with live pharmacy stock. End Visit saves everything in one call.

The patient profile aggregates across all visits: complaint frequency, BP trend, investigation trends, examination findings, latest drug chart.

πŸ–Ό Screenshot: Doctor visit page Β· docs/images/clinical-doctor-visit.png

πŸ–Ό Screenshot: Patient profile with trends Β· docs/images/clinical-patient-profile.png

Nurse

Read-only patient history and visit summaries.

πŸ–Ό Screenshot: Nurse view Β· docs/images/clinical-nurse.png

Pharmacist (GP module)

Dispensing queue fed by the GMC core over the module API, stock ledger with expiry and low-stock tracking, and a restock log.

πŸ–Ό Screenshot: Pharmacy dispensing queue Β· docs/images/clinical-pharmacist.png


Reports

Every report is generated for one unit, so /reports asks which one first. The options are the user's own units; an admin holds no unit assignments and falls back to their institution's units. One unit auto-selects; none shows an empty state.

The chosen unit rides in ?unit_id= and is re-resolved against the user's units on every print action, returning 403 otherwise β€” the id comes from a form field, so a user must not be able to print another unit's data by editing it.

The clinic confirmation letter is a signed clinical document: it names the unit in the letterhead and body, and signs with the generating doctor β€” name, specialty, qualification, registration number β€” not a generic "Medical Officer In Charge".

Modules register their own reports; the index shows them only when the unit's template matches and the module is enabled.

πŸ–Ό Screenshot: Reports index Β· docs/images/reports.png


Database Schema

institutions      (parent_id self-ref β†’ hierarchy)
modules           (code, version, manifest, status)      ← no row = not installed
module_migrations (per-module ledger)
module_files      (audit of copied files)
components        (code, slug, view, props/requires JSON, module_id, status)
component_usages  (component_id, module, view, short_code, box, category)  ← DERIVED
unit_templates    (module_id NULL = core, capabilities JSON)
view_templates    (blade_path, unit_template_id, module_id)
units             β†’ unit_views β†’ user_views                ← assignment chain
patients          (FHIR R4 Patient, column by column)      ← the common registration record
clinic_visits     β†’ visit_notes, blood_pressure_readings, investigations, visit_drugs
terminology_categories (code_system) β†’ terminology_terms
concept_mappings  (polymorphic: drug_names + terminology_terms)
rxnorm_concepts   (local mirror)
api_keys          (sha256 hash, scopes, expiry, revocation)

Core migrations are consolidated in database/migrations/2026_08_01_*, one create per table. Module tables ship inside their packages and are created only by the installer.

Module-owned tables reference core rows as plain ints β€” never FK constraints across the boundary.


Testing

# One-time: the suite runs against a SEPARATE database
mysql -u root -p -e "CREATE DATABASE phims_test;"

php artisan test
php artisan test tests/Feature/ModuleSystemTest.php

phpunit.xml points at phims_test so RefreshDatabase cannot drop your development data.

The suite takes roughly 16 minutes β€” the module tests re-migrate per test. For a quick check of blade changes, php artisan view:cache compiles every template in about a second and catches syntax errors without touching the database.


Troubleshooting

Antivirus deletes test files. Some AV products quarantine PHP test files when PHPUnit executes them, and the deletion looks like a missing file. Whitelist the repository folder, then git checkout -- tests/.

Absolute URLs point at the wrong path. APP_URL must match the folder name exactly. Browsing still works because you type the real path, but generated redirects will not.

A module's page shows "module disabled". Install β‰  enable. A fresh install lands switched off; press Enable, or use module:install <code> --enable.

route:cache breaks module routes. Do not use it. Module routes are database-conditional.

A component renders nothing. Check it is enabled at Admin β†’ Components, and that its owning module is enabled.

A new <x-ohims.* /> tag does not appear in the usage table. Run php artisan components:sync.


License

MIT.

Contributing

Issues and pull requests welcome at github.com/madusankabibile/OpenHIMS2-core.

Two things worth reading before a substantial change:

About

OpenHIMS2 API and web application code - A free, open-source Health Information Management System built with Laravel 10 and MySQL. Designed for multi-clinic hospital networks, it supports patient registration, clinical workflows

Topics

Resources

Stars

12 stars

Watchers

8 watching

Forks

Releases

Packages

Contributors

Languages