A hospital management system Laravel build is not one app, it is six or seven interlocking modules — patients, appointments, doctors and staff, billing, pharmacy/inventory, and medical records — that all share the same patient at the centre. The mistake I keep seeing kill these projects is treating it as a CRUD admin panel and bolting modules on as features arrive. The fix is to nail the schema and the module boundaries before you write a controller. Get the foreign keys, the audit trail, and the appointment-conflict logic right up front, and the rest is wiring. Get them wrong and you are running a data migration on live patient records, which is the last place you want to be.
What are the modules and how do they relate?
Draw the boundaries by responsibility, not by table. Each module owns its data and exposes the rest through relationships. The flow is almost always the same: a patient books an appointment, the appointment is assigned a doctor at a branch, the visit produces a medical record and possibly a prescription, and the prescription plus consultation produce an invoice. Inventory sits beside pharmacy and is decremented when a prescription is dispensed.
- Patients — demographics, contact, insurance. Owns the patient identity everything else points at.
- Doctors & Staff — links to the users table for auth, plus specialty, schedule, and branch assignment.
- Appointments — the scheduling core; belongs to a patient and a doctor, has a status lifecycle (booked, checked-in, completed, cancelled, no-show).
- Medical Records — clinical notes, diagnoses, attachments; append-only and audited, never edited in place.
- Pharmacy / Inventory — drug catalogue and stock levels; dispensing draws down inventory.
- Billing — invoices and line items generated from consultations, procedures, and dispensed drugs.
Keep clinical and financial concerns in separate modules even though they touch the same appointment. Billing rules change for legal and insurance reasons far more often than clinical structure does, and you do not want a billing migration risking medical-record integrity. The same boundary discipline is what later lets you scope everything by branch — whether a branch is a wing of one hospital or a separate facility.
What does the core schema look like?
Here is the spine of the schema. Note the indexes — the appointment lookups by doctor and by date run on every screen of the system, so they are not optional. I use foreignId for the keys, restrictOnDelete for anything clinical (you must not be able to delete a patient who has records), and a composite index that makes conflict checks fast.
Schema::create('patients', function (Blueprint $table) {
$table->id();
$table->foreignId('branch_id')->constrained()->restrictOnDelete();
$table->string('mrn')->unique(); // medical record number
$table->string('first_name');
$table->string('last_name');
$table->date('date_of_birth');
$table->string('phone')->index();
$table->json('insurance')->nullable();
$table->timestamps();
$table->softDeletes();
});
Schema::create('doctors', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('branch_id')->constrained()->restrictOnDelete();
$table->string('specialty')->index();
$table->unsignedSmallInteger('slot_minutes')->default(30);
$table->timestamps();
});
Schema::create('appointments', function (Blueprint $table) {
$table->id();
$table->foreignId('patient_id')->constrained()->restrictOnDelete();
$table->foreignId('doctor_id')->constrained()->restrictOnDelete();
$table->foreignId('branch_id')->constrained()->restrictOnDelete();
$table->dateTime('starts_at');
$table->dateTime('ends_at');
$table->string('status')->default('booked'); // booked|checked_in|completed|cancelled|no_show
$table->timestamps();
// The index every scheduling query depends on
$table->index(['doctor_id', 'starts_at', 'ends_at']);
});The downstream tables hang off appointments. A medical_records row references the appointment and is treated as immutable; prescriptions belong to a record and have many prescription_items; invoices reference the appointment and have many invoice_items, each pointing at a billable (a consultation fee, a procedure, or a dispensed drug). I use soft deletes on patients but never on medical_records — clinical history is append-only by design, and that distinction is worth being explicit about, which I dig into in soft deletes vs archiving in Laravel.
How do you keep a doctor from being double-booked?
This is the hardest part of the domain and where most homegrown systems fall over. The naive check — query for overlapping appointments, then insert — has a race condition: two receptionists booking the same 10:00 slot can both pass the check before either insert lands. The subtlety most write-ups miss is that lockForUpdate() on the overlap query alone does not fix it. When no conflicting row exists yet, the SELECT ... FOR UPDATE matches zero rows, so there is nothing to lock, and both transactions sail through. On Postgres at the default READ COMMITTED isolation level there are no gap locks to save you. The reliable fix is to take a lock on a row that always exists — the doctor — so concurrent bookings for that doctor serialise on it.
public function book(Doctor $doctor, Patient $patient, CarbonInterface $startsAt): Appointment
{
$endsAt = $startsAt->copy()->addMinutes($doctor->slot_minutes);
return DB::transaction(function () use ($doctor, $patient, $startsAt, $endsAt) {
// Serialise concurrent bookings for THIS doctor on a row that
// always exists. A FOR UPDATE on the (possibly empty) overlap
// query would lock nothing and let both bookings through.
Doctor::whereKey($doctor->id)->lockForUpdate()->firstOrFail();
// Standard overlap test: existing.start < new.end AND existing.end > new.start
$conflict = Appointment::where('doctor_id', $doctor->id)
->whereNotIn('status', ['cancelled', 'no_show'])
->where('starts_at', '<', $endsAt)
->where('ends_at', '>', $startsAt)
->exists();
if ($conflict) {
throw new SlotUnavailableException($doctor, $startsAt);
}
return Appointment::create([
'doctor_id' => $doctor->id,
'patient_id' => $patient->id,
'branch_id' => $doctor->branch_id,
'starts_at' => $startsAt,
'ends_at' => $endsAt,
]);
});
}The overlap condition is the classic interval test: an existing appointment conflicts when its start is before the new end and its end is after the new start. Because the second transaction blocks on the doctor's locked row until the first commits, it re-runs the overlap check against the freshly inserted row and correctly sees the conflict. If you want a true backstop independent of application code, add a database-level exclusion constraint (Postgres EXCLUDE USING gist with an int8range/tstzrange) so the engine refuses an overlapping pair even if a code path forgets the lock. Pulling this into a dedicated service class rather than a controller also makes it trivial to test the race in isolation — see testable service classes in Laravel for how I structure these.
If your scheduler can't survive two receptionists clicking 'book' on the same slot at the same second, you don't have a scheduler, you have a bug waiting for a busy Monday.
How do you handle roles, audit, and multiple branches?
Access control here is not cosmetic — a receptionist can see appointments and demographics but must not read clinical notes, a doctor reads and writes records for their own patients, and an admin manages staff and billing. Model this with Laravel policies rather than scattering role checks through controllers. The policy is the single place the rule lives, and it is the thing you point an auditor at.
public function view(User $user, MedicalRecord $record): bool
{
if ($user->hasRole('admin')) {
return true;
}
// Doctors only see records for appointments they are assigned to,
// and only within their own branch.
if ($user->hasRole('doctor')) {
return $record->appointment->doctor->user_id === $user->id
&& $record->branch_id === $user->branch_id;
}
// Receptionists never read clinical notes.
return false;
}For compliance you need to know who looked at what and who changed what — every read and write on a medical record should leave a trail. I do not reinvent this per project; I drop in a reusable, model-agnostic logger described in my reusable audit log system in Laravel post, which records the actor, the model, the changed attributes, and the request context. For multi-branch support, scope everything by branch_id and apply an Eloquent global scope so queries cannot accidentally leak across sites.
Start the project by drawing this module map and writing these migrations, not by scaffolding screens. The schema is the contract every module signs, and the two pieces that will hurt you most if you defer them — the append-only audited medical record and the race-safe scheduler — are exactly the ones that are cheap to build first and brutally expensive to retrofit. Build them on day one, write the test that proves two concurrent bookings produce one appointment and one rejection, and the rest of the hospital system becomes ordinary Laravel work.

