ICTHospital is our open source hospital management system. Until recently it ran on CodeIgniter 2.2.2, a framework that reached end of life in 2017 and will not start on PHP 8. Patching an unsupported framework that carries patient records was not a serious option, so we rebuilt it on Laravel 11.
The rebuild itself was the boring half. The interesting half was what we found once the application could be reached at all. This is a write-up of both, because the second half is the part that transfers to whatever legacy system you are looking at.
Where it started
Rather than start from an empty Laravel install, we forked ICTSchool, our school management platform. The two share a great deal: users, roles, settings, messaging, accounting, and the integration with our own communications framework. Forking gave us that spine on day one.
It also gave us a school. Students, classes, sections, subjects, exams, marks, GPA rules, timetables, library books, dormitories, and a family fee voucher chain that billed parents monthly. The hospital tables sat alongside all of it, sharing a database with a system that thought it was running a school.

The public website module had never run anywhere
The first thing we went after was a set of routes that returned nothing but server errors. Nine of them belonged to a public website module: a home page slider, testimonials, an about page and a subscribe form, all administered from inside the hospital application.
The controllers imported six models. None of those models existed. Not in our fork, and not upstream in ICTSchool either. This module had never worked anywhere, in any install, for as long as the code had been public. It had simply been carried along.
That is worth pausing on. A module can sit in a repository for years, get counted in the feature list, and be entirely fictional. The only reason we noticed is that we asked every route to respond and wrote down what came back.
Rewriting the school, not deleting it
Two scheduled jobs mattered enough to rewrite rather than remove.
The first walked the student table every month, joined two billing tables to find who had not paid their school fees, and texted the pupil’s father. The hospital equivalent is a patient who owes money on an invoice, so it now reads the payment table for rows where the gross total is ahead of the amount received, and texts the patient. Same plumbing, different question.
The second chased absent students. A hospital does not take attendance, it books appointments, so that became an appointment reminder for a target day. Both jobs now report why they did nothing instead of throwing, which means you can put them on cron before the messaging side is configured and they will politely tell you what is missing.
The messaging module itself was pointed at patients and doctors instead of students, parents and teachers. While we were in there we found a previous customer’s brand name hardcoded as the SMS sender mask in four places. It now comes from the hospital name in settings, which is what it should have done all along.
The permission catalogue was in two places at once
Roles are granted through a grid: permissions down the side, roles across the top, a toggle in each cell. The list of permissions was defined twice, once in the controller that saves the grid and once inside the template that draws it.
The template read the saved rows by numeric offset. Row seventeen of the admin block, then row seventeen of the next block, and so on. That works exactly as long as both copies of the list stay in the same order, and silently reads the wrong permission the moment they do not.
It also had a plain bug that nobody had noticed. The accountant column posted its values under one name while the controller read another, so accountant permissions were accepted by the form and thrown away on save. We moved the catalogue into a single configuration file that both sides read, and replaced the offsets with a lookup by name. Ninety hospital permissions across four roles, defined once.
A schema you cannot check is not a schema
The database came across from the original application untouched, which meant dates stored as text and relationships stored as text as well. An appointment held a patient id in a varchar column. Nothing stopped that column holding a patient who had been deleted, or a date that was not a date.
The obvious fix is an ALTER statement. The obvious fix is also how you turn somebody’s production data into nulls, because the moment one value does not parse, MySQL will happily write a zero date and move on.
So the converter asks first. It only changes a date column when every value in it parses. It only adds a foreign key when every value is numeric and matches a real row in the parent table. Anything else is left exactly as it is, and reported with the reason and an example.

We tested it by breaking things on purpose. A patient with the birth date “not a date”, and an appointment pointing at a patient id that does not exist. Both were skipped and named in the report while everything else converted. After fixing the two bad rows, a second run finished the job, and the new constraint then refused an orphan insert outright. On a clean database it converts 41 date columns and adds 16 foreign keys.
It runs as a migration too, so an existing install upgrades with a normal php artisan migrate. Read the report before you apply it to live data.
The bit worth stealing: a permission gate was hiding ten bugs
We check the rebuild with a smoke test. It signs in, requests every route that takes no parameters, and records the status code. Crude, and far more useful than it sounds, because it exercises the whole stack rather than a mocked slice of it.
It got us from fourteen server errors to zero. Then we granted the admin role its permissions, which we had reset while rewriting the catalogue, and three new server errors appeared on pages that had been reporting as fine.

The permission middleware had been redirecting those requests before the controller ever ran. A redirect is not an error, so the test counted it and moved on. The controllers behind the gate had never executed, in the test or in the application, for as long as the permission had been unset.
What was behind it: a table that had a model and a query and no migration, so the page could not have loaded on any fresh install ever. A route pointing at a controller method nobody had written. A form reading its configuration rows by position, which failed whenever fewer than two rows existed. And in the accounting reports, seven queries that both crashed on Laravel 11 and concatenated URL parameters straight into SQL.
That last one is the reason this section exists. Those report endpoints were injectable, and the reason nobody had found it is that nobody could reach them.
The general lesson: a test that treats “did not crash” as “worked” will report a locked door as a healthy room. If your test user cannot reach a page, your test is not testing that page. Run the suite with permissions granted as well as denied, and count what each request actually did rather than whether it threw.
The seeder had never worked
One more, in the same spirit. The database seeder loaded seven SQL files of demo school data. Six of those files were not in the repository and never had been.
Which means php artisan migrate --seed, the command in the installation instructions, failed for every person who had ever followed them. It now seeds an administrator, an empty hospital record, the permission catalogue and the notification types, and no invented patients, because a hospital system should not ship with fictional people in it.
Where it stands
The administrative and communication half runs: authentication, roles and permissions, the hospital dashboard, search, messaging over ICTCore with SMS logging and templates, accounting, settings, branches and an activity log. The clinical and front desk screens are the next body of work. Their tables, migrations and models are all there waiting for controllers.
Thirty-three pages serve with no server errors, up from twenty-two serving with fourteen errors. A clean install is 84 tables instead of 117.
The whole thing is GPL-3.0 on GitHub, including the smoke test, the schema moderniser and the tool that finds unreachable templates. The product pages and a screen by screen guide are at icthospital.com.
Common questions
Why fork a school system instead of starting fresh?
Because roughly half of any management system is the same regardless of what it manages: users, roles, settings, messaging, accounting, audit logging. Forking gave us all of that working on day one. The cost is that you inherit the other half too, and you have to be honest about removing it rather than leaving it to rot in the repository.
Is it safe to run the schema moderniser on live data?
Run it without the apply flag first and read what it says. It will not convert a column that contains anything it cannot parse, and it will not add a constraint that any existing row would violate. Take a backup anyway. It alters table definitions, and no tool should be trusted more than a backup.
What happens to my data if I upgrade from the old version?
Table and column names were kept exactly as they were, on purpose, so the result can be diffed against the original schema. Your data stays where it is. The moderniser changes column types where the values allow it and adds constraints where the relationships are already clean.
Does it need ICTCore to work?
No. Messaging is the only part that needs it. The clinical and billing side runs without any of it configured, and the two reminder jobs will tell you what is missing rather than failing.
Can I use it in production now?
Not for the clinical modules, which do not have their screens yet. If you want to run the administrative side, or you want to build the remaining modules with us, the issue tracker and the contact form are both open.
