Engineering
Why Laravel Is the Right Stack for HIPAA Software
Earlier this month we wrote about what building a HIPAA-compliant platform actually takes. It covered a multi-tenant dental referral platform, three months of work, and a scorecard: all twelve technical safeguards done, most of the administrative work barely started. That post was about the whole compliance programme. This one covers the part we left out: the stack.
When a client asks us whether Laravel is a serious choice for healthcare software, they usually aren't asking whether it can be secured. Any mainstream framework can. What they want to know is this: once the app has two hundred endpoints and the team has changed a few times, how much of the security still holds on its own, and how much depends on people remembering?
This post is our answer, with real patterns from the build. The code is simplified and renamed, but every piece of it runs in production.
What the platform runs on
Laravel is often chosen because teams enjoy working in it. What matters in healthcare is that it holds up under real load and real rules. This platform runs on:
- PHP 8.5 with strict types, backed enums for every domain value (roles, referral stages, audit actions) and readonly value objects.
- PostgreSQL, with row-level tenancy across practices. One user can hold different roles in different practices.
- Queues (Horizon on Redis) for every email, notification, export and file-processing step.
- WebSockets (Reverb) for live referral threads and notification badges.
- Two-factor authentication (TOTP) with single-use codes and recovery codes.
- About 2,350 automated tests. They run in parallel in about 40 seconds, and CI fails any pull request that drops coverage below 90%.
Almost all of this comes from Laravel itself, not from outside vendors. In a HIPAA project, that turns out to be a big deal.
1. Fewer vendors, fewer Business Associate Agreements
In the previous post we said every service that touches PHI needs a Business Associate Agreement (BAA), and the chain has to be complete. So every hosted service in your setup means one of two things. Either you sign a contract with that vendor, or you write down how you keep PHI away from it. Both take work, and both add risk.
Laravel lets you run much of this yourself:
| Need | Common hosted choice | What we used | Does PHI leave your servers? |
|---|---|---|---|
| Real-time messaging | Hosted WebSocket service | Reverb (self-hosted) | No |
| Queue monitoring | Third-party dashboard | Horizon | No |
| Feature flags per tenant | Feature-flag service | Pennant (database driver) | No |
| Full-text search | Hosted search | Scout + self-hosted engine | No |
| App performance metrics | APM vendor | Pulse | No |
Live referral threads carry clinical messages. With a hosted WebSocket provider, every one of those messages would pass through a third party. With Reverb, they stay on servers already covered by our cloud provider's BAA.
This is one of the best reasons to pick Laravel for healthcare, and one of the least talked about. You build faster, and you also have fewer vendors to vet, sign and track.
2. Access rules live in one place, and everything uses them
HIPAA's access control requirement (§164.312(a)(1)) is easy to meet on day one. It's also easy to break on day ninety, when someone adds a CSV export or a WebSocket channel and forgets the rule.
Laravel gives you two tools that make the rule hard to forget.
A policy answers "can this user see this record?":
class ReferralPolicy
{
public function view(User $user, Referral $referral): bool
{
// Participants see their own referrals, but only while they
// still hold an active membership in one of its practices.
if ($referral->involves($user)) {
return $user->isActiveIn($referral->practiceIds());
}
// Practice owners and admins see everything their practice
// sent or received.
return $user->practicesWhereCan('view_all_referrals')
->intersect($referral->practiceIds())
->isNotEmpty();
}
}
A query scope answers the same question for lists:
public function scopeVisibleTo(Builder $query, User $user): Builder
{
$managed = $user->practicesWhereCan('view_all_referrals')->pluck('id');
return $query->where(function (Builder $q) use ($user, $managed) {
$q->whereIn('sending_practice_id', $managed)
->orWhereIn('receiving_practice_id', $managed)
->orWhere(fn ($q) => $q->involving($user)
->whereIn('sending_practice_id', $user->activePracticeIds()));
});
}
Every screen goes through one or the other: the list page, the detail page, the export job, search results and the live channel. Laravel's broadcasting lets a channel class call the same policy, so live updates follow exactly the same rules as normal page loads:
class ReferralChannel
{
public function join(User $user, string $publicId): bool
{
$referral = Referral::where('public_id', $publicId)->first();
return $referral !== null && $user->can('view', $referral);
}
}
This helped us more than we expected. Midway through the project we removed the idea of a "current practice" altogether. Access is now worked out per record, from every practice a user belongs to. (The previous post explains why.) That changed the security model of the whole app. But the security model lived in one policy and one scope, so we rewrote those two, and the tests showed us every place the rest of the code disagreed.
Two smaller habits help too:
- Public IDs only. Every table that shows up in a URL has an internal number key and a separate random ID (a ULID). Route model binding looks records up by the random ID through
getRouteKeyName(). Nobody can guess the next record's URL, and no developer has to remember to hide the real ID. - Only the fields a page needs. Pages get plain arrays built with
$model->only([...]), not whole models. Adding a column to a table never sends it to the browser by accident.
3. The audit trail is one call and one enum
The previous post argued that you have to log reads: who opened a patient's thread, and who created a download link. Here's how Laravel keeps that consistent across about 60 logged actions.
Each action is a case on a backed enum. A typo breaks the build, instead of quietly logging an event nobody will ever search for:
enum AuditAction: string
{
case ReferralCreated = 'referral.created';
case ReferralThreadViewed = 'referral.thread_viewed';
case FileUrlGenerated = 'file.url_generated';
case LoginFailed = 'auth.login_failed';
case MfaDisabled = 'mfa.disabled';
// ...
}
Every log entry goes through one method. The table has no updated_at column, because entries are only ever added, never changed:
AuditLog::record(
action: AuditAction::ProfileUpdated,
subject: $user,
practiceId: null, // a user record belongs to no single practice
meta: ['changed' => ['phone']], // field names, never values
request: $request,
);
When a log entry belongs to a database transaction, it's sent as a queued job with afterCommit(). If the transaction fails, the job never runs:
WriteAuditLogJob::dispatch($entry)->afterCommit();
It's a small line, but without it your audit log can show events that never happened.
4. Encryption beyond the disk
Encrypting the database and disks is a job for your hosting setup. Laravel covers what that can't: secrets stored inside rows, and PHI waiting in a queue.
Encrypted columns take one cast each:
protected function casts(): array
{
return [
'mfa_secret' => 'encrypted',
'mfa_recovery_codes' => 'encrypted:array',
];
}
Encrypted queue jobs. When a specialist gets a new referral, the email job holds that referral in Redis until a worker picks it up. One interface keeps it encrypted while it waits:
class SendReferralAssignedEmail implements ShouldQueue, ShouldBeEncrypted
{
use Queueable, SerializesModels;
public function __construct(
public readonly Referral $referral,
public readonly int $recipientId,
) {
$this->afterCommit();
}
}
Every job and email in the platform that carries PHI uses ShouldBeEncrypted. It's one word in the class line, so a reviewer spots a missing one right away.
Encrypted sessions. Set SESSION_ENCRYPT=true, store sessions in Redis, and mark cookies secure and httponly. That's all settings, no code.
5. Login and session rules live in the routes
If developers have to remember to add a check to every controller, one day someone won't. Laravel 11 and later register middleware in one file, bootstrap/app.php. You declare each security check once and apply it to whole groups of routes:
->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [PreventBackHistory::class]);
$middleware->alias([
'mfa.enrolled' => EnsureMfaEnrolled::class,
'mfa.pending' => RequireMfaChallenge::class,
'baa.accepted' => EnsureBaaAccepted::class,
]);
})
Route::middleware(['auth', 'verified', 'mfa.enrolled', 'baa.accepted'])
->group(function () {
// Every clinical screen lives in here.
});
One check is worth pointing out: baa.accepted. The platform won't show a practice any clinical screen until someone from that practice has accepted the BAA. A legal requirement, enforced in one line of routing.
Rate limits are named and keyed by account. The two-factor code check is limited per login attempt, not per IP address. Otherwise, everyone in a practice sharing one office internet connection would share one limit:
RateLimiter::for('mfa-challenge', fn (Request $request) =>
Limit::perMinute(5)->by($request->session()->get('mfa.pending_user_id'))
);
Automatic logoff (§164.312(a)(2)(iii)) takes three things. A 15-minute SESSION_LIFETIME, a small idle timer in the front end that pings the server, and one middleware that's easy to forget:
class PreventBackHistory
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$response->headers->set('Cache-Control', 'no-store, no-cache, must-revalidate, private');
return $response;
}
}
Without it, picture a front-desk computer. Someone logs out, the next person presses Back, and the last patient record shows up again. The browser serves it from its own cache, and your server never sees the request.
6. Files and links that expire, and secrets you never store
Dental referrals carry X-rays, DICOM scans and PDFs. Laravel's storage layer sits in front of a private file store, and nothing is ever public:
- Downloads use signed URLs that expire in minutes. The app only creates one after checking access and logging a
file.url_generatedentry. - Uploads go straight from the browser to storage using presigned requests. The server decides the file type from the extension, not from what the browser says it is.
Some links work without a login, like the buttons in an email that let a specialist update a referral's status. For those we store only a hash of the token:
public function issue(Referral $referral, ReferralStage $target): string
{
$plain = Str::random(64);
$referral->statusTokens()->create([
'token' => hash('sha256', $plain),
'stage_target' => $target,
'expires_at' => now()->addHours(48),
]);
return $plain; // goes in the email, never saved
}
Two-factor recovery codes work the same way. If the database leaked tomorrow, none of these tokens could be used. Other public links, like invitations, use Laravel's signed route middleware. Those URLs expire and can't be edited, with no custom code needed.
7. Tests as proof
An auditor doesn't want to hear that access is restricted. They want proof. Tests that check a user is refused are that proof, and Pest makes them short enough that people actually write them:
it('refuses a practice that is not part of the referral', function () {
$referral = Referral::factory()->create();
$outsider = userWithRole(PracticeRole::Owner, Practice::factory()->create());
actingAs($outsider)
->get(route('referrals.show', $referral))
->assertForbidden();
});
Every case where one practice might see another practice's data has a test like this. That covers pages, live channels, file downloads and exports. When we changed the tenancy model, these tests found every spot where the old code broke the new rules.
The previous post warned that "a green suite proves your fixtures agree with your code". That still holds. What Laravel adds is speed. Parallel testing gives each worker its own database automatically. That's why about 2,350 tests finish in about 40 seconds, and why the full suite runs on every pull request.
8. Where the defaults need experience
Laravel's defaults take you most of the way. Experience covers the rest. The previous post covered the biggest leaks: file names in logs, cloud errors that exposed signed URLs, and search indexes holding clinical notes. Here are three more, all at the framework level:
redirect()->intended()doesn't check permissions. It sends the user to the last URL saved in their session. On a login form, that's fine. On a password-reset or invitation link, the saved URL may come from an older session and point at a page this user isn't allowed to see. We only use it where the user really was interrupted.- The built-in
encryptedcast fails on an empty value. If someone clears a field in a database tool, it often saves''instead ofNULL. That column is read at login, so every request for that account then fails, including the login page. We replaced the cast with a small custom one that treats blank as empty and still refuses access. - Locking one method doesn't lock the others. We locked the recovery-code check with
lockForUpdate()but missed the method that creates the two-factor secret. Two browser tabs on the setup page could each create a different secret. The user would scan a QR code that never works. Now, whenever we add a lock, we check every other method that reads and updates the same row.
None of these are bugs in Laravel. The framework gives you good tools, and experience tells you where they stop.
9. What no framework will do for you
Laravel made the technical safeguards the easy part. It did nothing for the rest of the scorecard: the risk analysis, staff training, device rules and vendor contracts. It also doesn't cover the hosting layer: encrypted databases and disks, locked log storage, TLS settings and backup drills. For that, the team who built the framework created Laravel Forge and Laravel Cloud, each with different levels of security protections.
That's why we choose it. Laravel covers the groundwork, so senior engineers can spend their time on the parts of HIPAA that need people.
In short
- Fewer vendors. Laravel's own tools for live updates, queues, feature flags, search and monitoring mean fewer BAAs to sign, and less PHI leaving your servers.
- Rules that stay put. Policies, scopes, form requests and shared middleware give every new endpoint the same checks. Reviewers look for the pattern instead of re-checking the whole design.
- Encryption past the disk. Encrypted columns, queue jobs and sessions each take a line of code or a setting.
- An audit trail you can trust. Typed events, entries that are never edited, and logging tied to transactions, so failed actions leave nothing behind.
- Proof, not promises. A fast test suite that checks every path between practices is refused.
We've built healthcare platforms on Laravel, from the first migration to the production audit trail. If you're choosing a stack for healthcare software, or want a second opinion on how ready an existing Laravel app is for HIPAA, start a conversation.
Written by
Abbas leads Ranium's technical vision and client delivery, working hands-on across system architecture, AI integration, and product development. He has years of experience building custom web and mobile applications using Laravel and PHP, and now brings that same depth to AI-powered solutions using models from Anthropic, OpenAI, and Groq. His work spans industries including healthcare, senior living, and property management.
Abbas organizes Laracon India and has spoken at various conferences and meetups on topics ranging from Laravel development to AI-assisted coding workflows. He believes small, focused teams can build remarkable products when they pair strong engineering fundamentals with the right AI tools.