Framework: Laravel 12
Runtime: PHP 8.5.3
A production-grade Laravel backend for managing patient referrals between hospitals and clinical staff, with AI-assisted triage, event-driven in-app notifications, audit logging, and role-based access control.
- Quick Start
- Architecture & Decisions
- Security Design
- API Reference
- Testing Approach
- Trade-offs
- What I Would Add With More Time
- Assumptions
# 1. Clone and enter directory
git clone https://github.com/jsoftsol/Healthcare-Referral.git && cd Healthcare-Referral
# 2. Copy environment file
cp .env.example .env
# 3. Generate app key (before Docker, so it's available in container)
php artisan key:generate # or manually set a 32-char base64 key in .env
# 4. Start everything with one command
docker compose up -d
# 5. The app container runs migrations and seeds automatically.
# Access the API at http://localhost/api/v1After seeding, credentials are printed in the healthcare_app container logs:
docker compose logs app | grep -A 10 "Seeded"
Default seeded credentials:
| Resource | Value |
|---|---|
| Admin login | admin@healthcare.local / password |
| Hospital 1 API Key | hsp_seed_hospital_one_key_12345678901 |
| Hospital 2 API Key | hsp_seed_hospital_two_key_12345678901 |
Requirements: PHP 8.5.3, MySQL 8.0+, Redis 7+, Composer 2
# 1. Install dependencies
composer install
# 2. Environment setup
cp .env.example .env
php artisan key:generate
# 3. Configure .env — set DB_*, REDIS_*, and generate a PATIENT_ENCRYPTION_KEY:
php artisan tinker --execute="echo base64_encode(random_bytes(32));"
# Paste the output as PATIENT_ENCRYPTION_KEY in .env
# 4. Run migrations and seed
php artisan migrate --seed
# 5. Start queue workers (in separate terminals or use Supervisor in production)
php artisan queue:work --queue=triage,escalations,notifications,default
# 6. Start the development server
php artisan servephp artisan test
# or with coverage
php artisan test --coverageLaravel 12 ships with Pest as the default testing framework, so no additional setup is required.
I structured business logic into single-responsibility Action classes (app/Actions/). Each action does exactly one thing:
SubmitReferralAction— creates patient + referral, handles idempotencyTriageReferralAction— calls AI, persists resultsAssignReferralAction— validates transition, assigns staffCancelReferralAction— validates transition, cancels referralEscalateReferralAction— checks eligibility, escalates and notifies admins
This makes each piece of logic independently testable and means controllers become thin routing/validation delegates — they have no business logic.
I deliberately skipped the Repository pattern. Laravel's Eloquent is already an active record implementation, and adding a repository layer for this domain would be indirection without meaningful benefit. If the system needed to swap out the data store, Eloquent's query builder abstraction already handles that.
All side effects (audit logging, AI dispatch, staff notifications, escalation scheduling) are decoupled from the core action via Laravel Events and Listeners:
ReferralSubmitted → DispatchAiTriageListener (queues AI job)
→ LogReferralAuditListener (writes audit log)
ReferralTriaged → NotifyStaffOnTriageListener (creates notifications, schedules escalation)
→ LogReferralAuditListener
ReferralAssigned → LogReferralAuditListener
ReferralStatusChanged → LogReferralAuditListener
This means adding a new side effect (e.g. a webhook to an external EMR system) requires zero changes to existing action or controller code — just a new listener.
Three separate queues with different priority/retry characteristics:
| Queue | Purpose | Retries | Backoff |
|---|---|---|---|
triage |
AI processing | 3 | 5s → 25s → 125s (exponential) |
escalations |
Emergency escalation timers | 1 | None |
notifications |
Staff notifications | 3 | Default |
default |
Everything else | 3 | Default |
The AI triage job uses exponential backoff to handle transient API failures gracefully without hammering a struggling upstream service.
The referral status machine is encoded in the ReferralStatus enum itself via allowedTransitions(). This means the business rules are co-located with the type, not scattered across multiple services. Any code that needs to check if a transition is valid calls $status->canTransitionTo($newStatus) — there's one source of truth.
pending → triaged → assigned → acknowledged → in_progress → completed
↘ ↘ ↘ ↘ ↘
cancelled cancelled escalated→ cancelled (final)
assigned
All API routes live under /api/v1/, configured globally in bootstrap/app.php using:
->withRouting(
api: __DIR__.'/../routes/api.php',
apiPrefix: 'api/v1',
)Laravel 12 automatically applies this prefix to all routes defined in routes/api.php, ensuring consistent versioning without repeating prefixes in route files.
This approach allows future versions (e.g. /api/v2) to be introduced by changing the prefix or loading additional route files.
The system has two distinct auth surfaces:
-
Hospital API Key — stateless, via
X-Hospital-Api-Keyheader. Keys are stored as SHA-256 hashes only (never plaintext). Verified by hashing the provided key and performing a constant-time comparison via database lookup. Handled byAuthenticateHospitalmiddleware. -
Staff JWT/Sanctum — token-based with explicit expiry (
SANCTUM_TOKEN_EXPIRY_MINUTES, default 60 min) and a separate refresh token. On fresh login, all existing tokens are revoked (single active session). Handled by Laravel Sanctum.
Patient identifiable fields (first_name, last_name, date_of_birth, national_id, insurance_number) are encrypted at the application level using Laravel's Crypt::encryptString() (AES-256-CBC) via the EncryptsPii trait. This means:
- Even if the database is compromised, PII is unreadable without the application key.
- The
national_idcolumn stores ciphertext, but a separatenational_id_hashcolumn stores an HMAC-SHA256 hash used for patient lookups — this enables deduplication without ever decrypting.
// Lookup patient by national_id without decryption:
$hash = hash_hmac('sha256', $nationalId, config('referral.patient_id_hmac_key'));
Patient::where('national_id_hash', $hash)->first();- The
Patientmodel overrides$hiddento exclude all PII fields from serialization. - The
Referralmodel hidesclinical_notes,ai_input_payload, andai_output_payload. - Global exception handling is configured in
bootstrap/app.phpusing->withExceptions()to ensure API-safe error responses. - A
toSafeArray()method onEncryptsPiimodels returns only{id, type}for logging.
Laravel 12 removes the legacy app/Exceptions/Handler.php requirement when exceptions are fully configured via bootstrap/app.php.
The CheckRole middleware (aliased role) enforces access at the route level:
role:admin— admin-only endpointsrole:admin,doctor,coordinator— all authenticated staff
Base path for every route below: /api/v1. All 11 endpoints follow the response envelope described at the end of this section.
| Field | Type | Rules |
|---|---|---|
email |
string | required, valid email |
password |
string | required |
Response data: staff {id, name, email, role, department}, access_token, refresh_token, token_type, expires_at.
No body. Revokes the current access token and issues a new one. Response data: access_token, expires_at.
No body. Revokes all tokens for the authenticated staff member (single active session). Response data: null.
| Field | Type | Rules |
|---|---|---|
patient.first_name |
string | required, max 100 |
patient.last_name |
string | required, max 100 |
patient.date_of_birth |
date | required, must be before today |
patient.national_id |
string | required, max 50 |
patient.insurance_number |
string | required, max 50 |
urgency_level |
string enum | required — routine, urgent, emergency |
icd10_codes |
array<string> | required, min 1 item, each matching ^[A-Z][0-9]{2}(\.[0-9A-Z]{1,4})?$ (e.g. I21, I21.0) |
clinical_notes |
string | required, min 10 chars |
department |
string | null | optional, max 100 |
Idempotent: resubmitting the same (hospital_id, patient_id, icd10_codes, urgency_level) combination returns the existing referral instead of creating a duplicate. Response data: ReferralResource (see shape below), HTTP 201.
| Query param | Type | Rules |
|---|---|---|
status |
string enum | optional — pending, triaged, assigned, acknowledged, in_progress, completed, cancelled, escalated |
urgency |
string enum | optional — routine, urgent, emergency |
department |
string | optional, max 100 |
date_from |
date | optional |
date_to |
date | optional, must be on/after date_from |
per_page |
integer | optional, 1–100 (default 20) |
Response data: paginated collection of ReferralResource.
No params. Returns a single ReferralResource with patient, hospital, assigned_staff, and full audit_history loaded.
| Field | Type | Rules |
|---|---|---|
staff_id |
integer | required, must exist in staff table |
Valid only from triaged or escalated status. Response data: updated ReferralResource.
| Field | Type | Rules |
|---|---|---|
reason |
string | required, 10–500 chars |
Valid from any non-final status. Response data: updated ReferralResource.
| Query param | Type | Rules |
|---|---|---|
date_from |
date | optional (default: 30 days ago) |
date_to |
date | optional (default: today) |
Response data: period {from, to}, total_referrals, referrals_per_day (map of date → count), average_ai_confidence, escalation_rate, cancellation_rate, escalated_count, cancelled_count.
No params. Returns the authenticated staff member's assigned referrals, paginated (20/page). Response data: paginated collection of ReferralResource.
No body. Marks the notification as read (only if it belongs to the authenticated staff member — 404 otherwise); this is the trigger that satisfies "acknowledged" for emergency escalation. Response data: NotificationResource {id, message, channel, referral_id, sent_at, read_at, is_read}.
Returned by every referral endpoint above.
| Field | Notes |
|---|---|
id, status, urgency_level, department, icd10_codes |
core referral fields |
hospital |
{id, name, code} |
assigned_staff |
{id, name, department} — only when loaded |
ai_triage |
{suggested_department, confidence_score, processed_at} — only once AI triage has run |
cancellation_reason |
null unless cancelled |
created_at, updated_at |
ISO 8601 |
patient |
{id, first_name, last_name, date_of_birth, insurance_number} — decrypted on read, only when loaded |
audit_history |
array of {id, action, field_name, old_value, new_value, metadata, performed_by, created_at} — only when loaded |
All responses follow this structure:
{
"success": true,
"message": "Human-readable message",
"data": { ... }
}Errors:
{
"success": false,
"message": "Validation failed.",
"errors": {
"urgency_level": ["The urgency level field is required."]
}
}I used Pest PHP for its expressive syntax and dataset support. Tests are organized into:
tests/Unit/Actions/— Tests for business logic in isolation. External dependencies (AI service) are mocked with Mockery. No database needed for pure logic tests.tests/Feature/Api/— HTTP-level tests usingRefreshDatabase. These test the full request/response cycle including auth, validation, and side effects.
Philosophy: I test behaviour, not implementation. Tests assert outcomes (what changed in the database, what events were dispatched, what HTTP response was returned) rather than internal method calls.
External dependencies are always faked or mocked:
Queue::fake()— prevents actual jobs from running in feature testsEvent::fake()— allows asserting events were dispatchedMockery::mock(AiTriageService::class)— unit tests the action without real HTTP calls
Given the 3–4 day constraint, I made the following deliberate decisions:
-
Mail sending is stubbed.
SendStaffNotificationJobhas a hook for email/SMS but doesn't wire up a real mail driver. The pattern is there; plugging inMail::to()->queue(new Mailable)is a 5-minute addition. Adding full Mailable classes with templates would be a distraction from architecture evaluation. -
No OpenAPI spec generated. I would use
dedoc/scrambleto auto-generate from the FormRequest and Resource classes — it's a single package install. Omitted to stay focused on the core system. -
Reporting is direct DB queries. For the scale implied by this assessment, direct aggregation queries are appropriate. At higher scale I would cache these reports (Redis TTL) or materialise them with a scheduled job into a
daily_report_summariestable. -
No rate limiting on hospital API. In production, I'd add
throttle:60,1per API key using Redis-backed rate limiting. Left out to keep the scope focused. -
Patient PII encryption uses Laravel's
Cryptfacade. In a real healthcare system, I would evaluate a dedicated KMS (AWS KMS, HashiCorp Vault) for key management so the encryption key rotation is separated from application deployments.
- FHIR R4-compatible payload format — a
FhirReferralTransformerthat maps the internal model to a FHIRServiceRequestresource would be straightforward to add given the clean data model. - Event sourcing for referral state — Laravel's built-in event system gets us audit logging, but true event sourcing (storing events as the source of truth rather than the current state) would give us full replay capability and point-in-time reconstruction. I'd use the
spatie/laravel-event-sourcingpackage. - WebSocket real-time notifications — broadcasting
ReferralAssigned/ReferralTriagedevents over Laravel Echo + Soketi for dashboard updates. - OpenAPI documentation — auto-generated via
dedoc/scramble. - Database encryption at the column level — using MySQL's native column encryption or a Vault-managed encryption as an additional layer on top of application-level encryption.
- Supervisor configuration — for managing queue workers and the scheduler in production.
- Horizon — Laravel Horizon for queue monitoring, retry management, and visibility into AI triage job throughput.
- Comprehensive pagination on audit logs — the current implementation loads all audit logs for a referral. At high volume, this should be paginated separately.
-
Department matching for notifications — The spec says "a cardiologist should only receive cardiac referrals." I interpreted this as department-based matching: the AI suggests a department, and staff with
department = ai_suggested_departmentreceive notifications. The spec doesn't define department taxonomy, so I used free-text strings (consistent with the data model described). -
"Acknowledged" as the escalation reset point — The spec says escalation triggers if not acknowledged within 2 minutes. I interpret "acknowledged" as the staff member explicitly acknowledging the notification (the
acknowledgeendpoint), which transitions the referral toacknowledgedstatus. TheEscalateEmergencyReferralJobchecks current status on execution — if it's already been acknowledged or progressed, it silently exits. -
Idempotent submission hash — Duplicate detection uses a hash of
(hospital_id, patient_id, icd10_codes, urgency_level). This means the same hospital can re-submit the same clinical situation for a patient without creating duplicates. A re-submission with different codes or urgency creates a new referral. -
Single active session per staff member — On login, existing tokens are revoked. This is a security posture choice (suitable for healthcare). If multi-device access is needed, this can be changed.
-
AI API contract — The external AI service is assumed to accept
{icd10_codes, clinical_notes, urgency_level}and return{department, confidence_score, reasoning}. The service layer (AiTriageService) isolates this contract so the implementation can be swapped without touching business logic.