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. No images are bundled with this repository.
- Requirements
- Installation
- First Run
- π Interoperability β the FHIR API
- π· Standards Coding
- π§© The Module System
- π§± The Component System
- Patient Registration
- Admin Setup
- Daily Clinical Use
- Reports
- Database Schema
- Testing
- Troubleshooting
- License
| 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.
# 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:8000Place 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_URLto 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.
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=AdminSeederLog 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
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
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.
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.
| 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}).
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
# 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.
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.
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.
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.
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
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 β 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.)
~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).
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.
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.
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 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
| 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) |
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 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.
$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.
- Module tables reference core rows as plain ints β never FK constraints across the module/core boundary. A constraint would make uninstall impossible.
route:cachemust not be used. Module routes are database-conditional; the installer runsroute:clearafter every change.- Copy targets are gitignored generated artifacts. Edit
modules/<CODE>/and re-install β never the copies.
php artisan module:install <code> [--enable]
php artisan module:uninstall <code>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
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.
| 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.
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.
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 UIThe module installer runs this automatically on install, enable, disable and uninstall.
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.
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.
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.
| 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
| 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.
Two access modes, split by role:
| Mode | Prefix | Who |
|---|---|---|
| Admin | /admin |
role = admin |
| Clinical | /clinical |
role = user |
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
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
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
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
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
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
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
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
Read-only patient history and visit summaries.
πΌ Screenshot: Nurse view Β·
docs/images/clinical-nurse.png
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
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
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.
# 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.phpphpunit.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:cachecompiles every template in about a second and catches syntax errors without touching the database.
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.
MIT.
Issues and pull requests welcome at github.com/madusankabibile/OpenHIMS2-core.
Two things worth reading before a substantial change:
CLAUDE.mdβ architecture and the reasoning behind each decisiondocs/COMPONENTS.mdβ the component system in full