Case study · Laravel · Filament · Multi-tenant

Building the system that tells a credentialing team what's about to lapse.

Credentialing SaaS keeps every provider's licences, enrollments and documents in one place. This is how it's put together: why the provider is the centre, how tenant isolation fails closed, how a document link is kept safe, and what making the screenshots turned up.

Framework
Laravel 13, Filament 5
Tenancy
Shared DB, fails closed
Billing
Razorpay
Tests
92 passing
A provider's page in Credentialing SaaS with a profile completion meter, missing sections and tabs for each credential section.

A credentialing team's real record of a provider is scattered across five places.

Provider credentialing is the paperwork that lets a clinician treat an insurer's members: verify their licences, certificates, insurance and history, then enrol them with each payor. A team does this for dozens of providers at once, and the truth about any one of them lives in a spreadsheet, a few inboxes and a shared drive. The failures are predictable: a licence lapses unnoticed, a document is asked for twice, an application stalls and nobody notices, and nobody can say who did what.

The product's job is to be the one place that answers, for any provider, what we have, what's missing, what's expiring and where each application stands. It also had to be sold: many organizations, each strictly separate, each on a plan.

A locked vision document, and a rule that says no.

The project is written down in a numbered series of documents: a vision, a scope, an architecture and a numbered series of documents covering the data model, each provider sub-section, permissions, security and billing. The vision is marked locked and says that if a feature doesn't support it, it should not be built. The central sentence is that the provider is always the centre: nothing revolves around documents, enrollments or payors, because those exist only to support a provider's credentialing.

That sounds like ceremony, and its value shows up in the code. Every credential section (education, training, licences, certifications, malpractice insurance, work history, references, affiliations) is a child of the provider and appears on the provider's page, with a completion meter that lists exactly which sections are still empty. It's also why the product refuses to grow ERP-style extras. It's a 49-migration application with a clear spine instead of a pile of modules.

One database, an organization id on every row, and a filter that returns nothing when unsure.

This product serves many small organizations, so I chose a single shared database with a tenant id on every tenant-owned table, unlike the hospital system I built earlier, which uses a database per hospital. The reasoning is different here: far more tenants and far less data each, where a database per tenant would be a burden, and the isolation is enforced by code and by tests instead of by the database boundary.

The enforcement is a global scope on every tenant-owned model. It adds WHERE tenant_id = current to every query, and if the app can't tell which tenant is asking, it adds WHERE 1 = 0, so a request with no tenant context sees nothing and not everything. A middleware sets the tenant for each request and roles are scoped per tenant. Then the tests try to break it on purpose: reading, updating and restoring another tenant's records, role isolation, and tenant resolution.

A provider needs to send a file. They don't need an account.

Credentialing means chasing providers for documents, and the usual way, attachments in email, loses files and leaks data. So the team creates a document request and the provider gets a secure link. The link carries an 80-character random token. The token is shown once in the link and only its hash is stored, so a copy of the database can't be turned back into working links.

A request has an expiry (14 days by default), a version number that lets the team revoke every outstanding link at once, a rate limit on the public portal, and a status that moves from sent to opened to submitted. Nothing the provider uploads counts straight away: every submission goes to a review queue where a team member accepts or rejects it. That separation of received and accepted is the real feature.

A daily clock instead of a person's memory.

The Expiry Center groups licences, certifications, insurance, privileges and CAQH items into buckets: expired, within 30 days, within 60 and within 90. A scheduled job scans for expiries at 06:00, another turns credentials into reminders at 06:15, and the reminders are processed hourly, notifications are sent every five minutes and a daily digest goes out at 07:00. Expiring items also create tasks with an owner and a due date, so the reminder turns into work that someone can be asked about.

Each user can control what they're notified about, and every delivery is recorded in a history, so "did the email actually go" has an answer. This is the part of the product that replaces memory, and it is the reason a lapse is supposed to stop being a surprise.

Plans, limits and a webhook you can't replay.

A plan is more than a price. It carries limits (users, providers, locations, storage) and switches (exports, imports, advanced reports, document requests, custom fields), and when a subscription starts those entitlements are copied into a snapshot, so changing a plan later doesn't silently change what an existing customer paid for. Payments, invoices and subscriptions run through Razorpay, and invoices and subscription authorisation have their own pay-by-link pages.

The part I care about is the webhook. Razorpay calls the app when something happens, and anyone could forge that call. So the handler checks Razorpay's signature on the raw request, and every event is recorded so a duplicate or replayed event is ignored instead of paying an invoice twice. The code is tested against its own rules. It has not been run against a live Razorpay account, and I'm not going to say it has.

The platform panel wouldn't let me in without a second factor, which is the point.

To make screenshots for this page I filled a throwaway database with invented data and signed in to both panels. The organization panel opened at once. The platform panel, the one that can see every customer, refused to proceed until the account had an authenticator app enrolled, because the design says multi-factor sign-in is mandatory for the platform super admin. I couldn't screenshot it without defeating that rule, and I'd rather leave the platform panel off this page than weaken the thing the page is praising.

Making the screenshots also found a bug. One of the reports, enrollment operations, throws an error because the page asks the user model for a relationship that doesn't exist. It's an honest example of why a product needs someone to actually click through it, and it's on the fix list.

Ninety-two tests, most of them about not letting things in.

The suite has 92 tests and 612 assertions, and it passes. They run against an in-memory database so they can't touch real data. They cover tenant isolation and context resolution, role and policy isolation, the document request portal, tenant signup, location and user services, custom-field values, auditing, both Razorpay services, bulk-import security, and an architecture test that guards file and class naming. The pattern is deliberate: the security document lists the tests a product like this must have, such as cross-tenant access, token replay and webhook replay, and the suite follows that list as far as it has got.

It isn't complete. The baseline lists more than is built, including malware scanning of uploaded files, so I treat it as the target and the tests as the evidence, and I only claim what the tests show.

What's actually running under it.

Application
Laravel 13 on PHP 8.3, with about 68,000 lines under app/: models, enums for every status, policies, services for each domain and Livewire pages.
Admin UI
Filament 5 with two panels: one for an organization's team (providers, enrollments, requests, reports, users) and one for the platform (tenants, plans, subscriptions, invoices, payments, coupons).
Tenancy
A shared MySQL database. A BelongsToTenant trait and a global TenantScope that fails closed, and a context middleware that sets the tenant.
Access
spatie/laravel-permission with roles per tenant: Tenant Admin, Credentialing Manager, Credentialing Specialist and Viewer, plus custom roles, and location-based visibility.
Documents
A request, items, a session and a review per submission, with hashed tokens and a versioned revocation.
Scheduling
Laravel's scheduler: a daily expiry scan, reminder scan and digest, and frequent queue processing for reminders and notifications.
Billing
Razorpay services for payments and subscriptions, an entitlement and usage service, coupons, and a de-duplicated, signature-checked webhook.
Audit
spatie/laravel-activitylog through an auditable trait, plus Pulse for application health.
Verification
92 passing PHPUnit tests on an in-memory database, with Larastan and Pint in the toolchain.

Honest about where it actually is.

It's a working multi-tenant application with a provider-centred data model, a full credentialing workflow, secure document requests, expiry tracking, reports, roles, and a platform side for plans and billing, held together by a written security baseline and a test suite that tries to break its own isolation.

What it isn't: launched, tested against live payments, or finished against its own security document. One report page is broken, malware scanning is not built, and the platform panel isn't shown here. I'd rather publish the gaps than let a buyer find them.

Running a credentialing company or medical group, or want a multi-tenant product built properly?

Get in touch