Skip to content

Every SaaS they started rebuilt tenants, plans and the audit trail, so we built those once

A tenancy and billing scaffold (tenants, pricing plans, per-user preferences and an activity log) built once as the starting point for a vendor who had written the same four things into four different products.

Harbor & Pike Architects
Tenants › harborpike · tn_1042
Activity for this tenantFind a tenant/Thu, Sep 17, 2026
HP
Harbor & Pike ArchitectsActiveGrowth plan
Subdomain harborpike · owner Marisol Ortega · created Mar 4, 2025
RenewsOct 1, 2026
Change plan
Seats76%
19of 25 seats
Checked in InviteMember
Storage43%
42.6 GBof 100 GB
Checked in StoreUpload
API calls91% · near
91,240of 100,000 this cycle
Checked in MeterApiCall middleware
ProjectsAt ceiling
25of 25 projects
Checked in CreateProject

Members

19 of 25 seats · 8 shown
MemberRoleSecond factorLast active
MOMarisol Ortegamarisol@harborpike.comOwner OnToday, 11:38 AM
DKDevin Kaplandevin@harborpike.comAdmin OnToday, 11:42 AM
RBRenee Blackwoodrenee@harborpike.comBilling OnYesterday
TATheo Asantetheo@harborpike.comMember OnToday, 10:15 AM
JLJun Lindqvistjun@harborpike.comMemberNot setToday, 9:02 AM
CSCarmen Salgadocarmen@harborpike.comMember OnSep 15
OPOwen Prattowen@harborpike.comMemberNot setSep 14
NWNadia Wexlernadia@harborpike.comMember OnSep 11
+ 11 more members Invite member · checked against 25 seats

Refused at a ceiling

Server-side, any channel
project.createToday, 11:42 AMprojects 25 of 25· Devin Kaplan viaapiUpgrade path returned: Scale · unlimited projects
project.createSep 16, 4:08 PMprojects 25 of 25· Theo Asante viawebUpgrade path returned: Scale · unlimited projects
member.inviteSep 9, 2:51 PMseats 25 of 25· Devin Kaplan viawebUpgrade path returned: Scale · 100 seats

Suspend tenant

Behind its own warning
Suspending stops every member of Harbor & Pike Architects signing in. It is logged against you and can be lifted.19 members signed out at onceData kept; API tokens pausedType the subdomain to confirmtenant.suspendedSuspend…

Who, what, and how long

Industry

Professional Services

Duration

8 weeks

Cooperation model

Fixed price

Services
Platform engineeringFoundationsInternal tooling
Integrations
Subscription billing providerEmail deliveryError trackingLog retention storage
Technologies
LaravelLivewireMySQLTailwind CSSAlpine.js
Team
1 Engagement lead2 Backend engineers1 Frontend engineer

Client name withheld under NDA. Engagement details are shown to the extent our agreement permits.

Before and after

What was there, and what replaced it

What was there

Four products, four implementations of tenancy, and four different bugs. In two of them, a plan limit was a number on a pricing page that nothing checked. In one, the activity log was added after an incident and couldn't answer questions about anything before it. None of them agreed on what a tenant even owned.

Each rebuild had gone wrong differently, and all under the same pressure. In two products, a plan limit was a number on a pricing page that nothing checked, enforceable only by hope. In one, tenant scoping was a where clause each query added, which held until someone wrote a query on a Friday. None of the four agreed on what a tenant even owned.

What replaced it

One scaffold that makes the easy-to-get-subtly-wrong parts hard to get wrong. Tenant scoping is applied globally, beneath every query. A plan limit is checked at the point of use. The activity log is written by the same layer that performs the action, so it can't be forgotten.

Introduction

What we inherited

The vendor starts several products a year, and each one had rebuilt the same foundation. We built it once: tenant isolation, pricing plans whose limits are actually enforced, per-user preferences, and an activity log that records who did what.

A vendor starting several products a year, each of which had rebuilt the same foundation: tenancy, plans, preferences, audit. Four products meant four implementations and four different bugs. The engagement was eight weeks to build it once, properly, and it was prompted by an incident in one product that the activity log couldn't explain because the log had been added after the fact.

Full-Stack Engineering

What we kept, and what we cut

  • Applied tenant scoping globally, so an unfiltered query can't happen by accident

    Global scoping won over per-query filters because the failure mode is omission, and omission is what happens when someone is in a hurry.

  • Made plan limits enforced at the point of use

    A plan limit is checked server-side where the resource is created and returns the specific limit hit with the upgrade path, so an API call can't bypass the interface.

  • Wrote the activity log from the layer that performs the action

    Writing the log from the acting layer was chosen after one of the vendor's products had to answer an incident with a log added after the fact.

  • Kept user preferences separate from tenant settings, because they answer different questions

    User preferences and tenant configuration are stored separately, because an administrator changing a company setting shouldn't overwrite an individual's choice.

  • Built the admin surface for the vendor's own staff, which is who actually operates a tenancy

    The operator console (tenants, plans, subscriptions, logs) shipped on day one, so nobody had to build it badly during a first incident.

Global tenant scoping

Isolation is applied below the queries, so a new feature can't make an unfiltered read by accident.

Tenant scoping is a global scope applied beneath every query, so no feature has to remember a where clause. That inverts the failure mode: an unscoped read has to be deliberately opted into and named, where before it was what happened when someone wrote a new query on a Friday. It's the single decision that determines whether a multi-tenant product eventually leaks.

What shipped
  • Global scope applied below every query
  • Unscoped reads must be opted into and named
  • A new query can't leak by omission
Isolation guard
Tenant scoping is one global scope beneath every query · what it covers, what it refused, what runs on deploy
All deploy checks passedFind a tenant/Thu, Sep 17, 2026
Implementations
1 global scope
Per-query tenant filters
0 none to forget
Models covered
34 of 34 tenant-owned
Refused this week
7 none reached a row
Named opt-ins
3 allowlisted

Models covered by the global scope

trait BelongsToTenant
ModelTableRowsScope
Projectprojects48,211scoped
Documentdocuments1,204,877scoped
Membermembers2,960scoped
Invoiceinvoices18,340scoped
ApiTokenapi_tokens1,122scoped
Uploaduploads611,405scoped
Commentcomments3,882,190scoped
Tasktasks902,556scoped
Webhookwebhooks734scoped
ActivityEntryactivity_log9,418,062scoped
+ 24 more tenant-owned models, all scopedGlobal by design · not tenant-owned
TenantThe boundary itselfglobal
PlanShared catalogglobal
OperatorVendor staffglobal

Unscoped queries refused this week

Thrown before the query ran
WhenOriginModelRefused with
Sep 17, 3:12 AMJobs\RebuildSearchIndexDocumentNo tenant context
Sep 16, 5:47 PMReports\ExportCsv@handleInvoiceNo tenant context
Sep 16, 9:20 AMtinker (staging copy)ProjectUnnamed opt-out
Sep 15, 2:04 PMJobs\PruneOldUploadsUploadNo tenant context
Sep 14, 11:31 PMConsole\SyncCalendarsTaskNo tenant context
Sep 12, 4:16 PMLivewire\DocSearchDocumentUnnamed opt-out
Sep 11, 8:58 AMJobs\SendDigestCommentNo tenant context

Checks that run on every deploy

deploy #412 · Today, 9:40 AM
Every tenant-owned model uses BelongsToTenant34 of 34passed
No raw query reads a tenant-owned table0 foundpassed
Every unscoped read is named and allowlisted3 of 3passed
Cross-tenant read test: two tenants, one factory0 crossedpassed
Named opt-insbilling.renewal-sweepreports.vendor-usagetenancy.provision
On screen

The isolation guard reporting on itself: one tenancy implementation and no per-query tenant filters, the models covered by the global scope, the unscoped queries it refused this week, the named opt-ins, and the checks that run on every deploy.

Plans
Four plans · every ceiling is checked where the resource is created, not only shown here
New planFind a tenant/Thu, Sep 17, 2026
Starter
$29/ month
61 tenants on this plan
Seats5
Storage10 GB
API calls10,000
Projects3
GrowthEditing
$99/ month
38 tenants on this plan
Seats25
Storage100 GB
API calls100,000
Projects25
Scale
$249/ month
17 tenants on this plan
Seats100
Storage500 GB
API calls1,000,000
ProjectsUnlimited
Enterprise
Custom
4 tenants on this plan
SeatsPer contract
StoragePer contract
API callsPer contract
ProjectsPer contract

Growth limits

Draft · not saved
LimitNowNew ceilingEnforced in
Seatsmembers2530InviteMemberon member.invite · web, API and queue
StorageGB100 GB100 GBStoreUploadon file.upload · web, API and queue
API callsper cycle100,000150,000MeterApiCall middlewareon api.request · web, API and queue
Projectsactive2525CreateProjecton project.create · web, API and queue
A refusal names the limit hit and returns the upgrade pathDiscardSave for next renewal

Who this change reaches

38tenantsOn Growth, each at its own next renewal, not today
Renewals by window
Sep 18 – Sep 309
Oct 1 – Oct 1512
Oct 16 – Oct 3110
Nov 1 – Nov 177
No tenant is over a new ceiling. Both changes raise a limit: seats 25 → 30, API calls 100,000 → 150,000.
Saving logs plan.limits.changed from UpdatePlanLimits
On screen

Four plans side by side with Growth's limits open for editing, the action each ceiling is enforced in, and a count of the tenants the change would reach at their next renewal.

Limits enforced, not displayed

A plan's limit is checked where the resource is created, and the pricing page just reports it.

A plan limit is enforced where the resource is created. The check runs server-side at the point of use and returns the specific limit that was hit with the upgrade path attached, so the user gets an answer instead of a bare refusal, and an API call that skips the interface can't bypass it.

What shipped
  • Enforced server-side at creation
  • The specific limit named, with the upgrade path attached
  • An API call can't bypass what the UI displayed

Audit by construction

The activity log is written by the layer performing the action, so a new feature can't forget it.

The activity log is written by the same layer that performs the action, so a new feature is logged simply by existing there. Its author doesn't have to remember. That's the only kind of audit trail that stays complete past the first six months; the ones written from call sites are always missing exactly the actions someone later needs.

What shipped
  • Written by the layer performing the action
  • A new feature is audited by existing
  • Stays complete past the first six months
Activity log
Written by the layer that performs each action · every tenant, every channel
Features that can skip the log: 0Find a tenant/Thu, Sep 17, 2026
All tenantsAll channelsAny actorLast houraction:plan.* OR outcome:refused
TimeActorTenantChannelActionTarget
11:42:08 AMDevin KaplanHarbor & Pikeapiproject.createrefusedlimit projects 25 of 25
11:39:51 AMPriya RamanoperatorCalloway Freightconsoleplan.changedStarter → Growth
What the upgrade alteredbefore → after
planstartergrowth
limits.seats525
limits.storage10 GB100 GB
limits.api_calls10,000100,000
limits.projects325
price$29 / mo$99 / mo · prorated
Written byApp\Actions\Plans\ChangePlanNot bythe Livewire component that called itRequestreq_8f2c41ad · 10.0.4.17Subscriptionsub_calloway · effective now
11:38:14 AMMarisol OrtegaHarbor & Pikewebmember.role.updatedTheo Asante · Member
11:36:02 AMsystemBellweather Studioqueuetrial.reminder.sent7 days left
11:31:47 AMAaron FeldQuarry Hill Clinicwebfile.uploadsite-survey-rev4.pdf
11:29:10 AMDana WhitfieldoperatorLumen Row Co-opconsoletenant.suspendedUnpaid · 3 notices
11:27:33 AMGrace IdowuNorthfork Surveyingapiapi_token.createdci-deploy
11:24:05 AMsystemCalloway Freightclisubscription.renewedStarter · Sep 17
11:22:41 AMHollis GrantQuarry Hill Clinicwebmember.inviterefusedlimit seats 5 of 5
11:20:19 AMMarisol OrtegaHarbor & Pikewebtenant.settings.updatedSession length · 12h
Showing 10 of 1,286 entries in the last hour activity_log · scoped per tenant, read across tenants only in this console
On screen

The activity log: actor, tenant and channel for every action, refusals included, with one plan change expanded to show what the upgrade altered and the action that wrote the entry.

FieldbookHPHarbor & Pike Architects· 2 tenants
Your preferences
Theo Asante · these follow you, not the tenant
TA

Notification channels

Per event
EventEmailIn-appPush
Mentioned in a comment
A task assigned to me
Weekly project digest
Security alerts on my account

Locale and display

LanguageEnglish (US)Time zoneAmerica/ChicagoDate formatMM/DD/YYYYThemeDarkDensityCompactDefault viewBoard

Second factor

Authenticator appAdded Feb 12, 2026 · 8 recovery codes leftOnRecovery codes

Sessions

Revoke any but this one
Chrome on macOSChicago, ILActive nowThis session
Fieldbook for iOSChicago, ILToday, 8:12 AMRevoke
Edge on WindowsEvanston, ILSep 15, 5:40 PMRevoke
Firefox on UbuntuDenver, COSep 2, 10:03 AMRevoke

Set by Harbor & Pike

Read-only here
Tenant settings, changed by an admin. A change there can't overwrite a choice on the left.
BrandingHarbor & Pike, navy markEveryone in the tenant
Default languageEnglish (US)Yours is set, so yours applies
Default themeLightYours is Dark, and stays Dark
Require second factorAdmins and OwnerPolicy, not a preference
Session length12 hoursPolicy, not a preference
Password policy12 characters minimumPolicy, not a preference
Data regionus-eastFixed at provisioning
Owner: Marisol Ortega

Stored apart

user_preferenceskeyed by user_idTheo's choicestenant_settingskeyed by tenant_idThe tenant's rulesResolved per read: your preference if set, the tenant default if not.

Preferences separated from settings

On screen

Personal preferences: notification channels, locale and display, second factor and revocable sessions, beside the tenant's own settings shown read-only and stored in a separate table.

A user's choice and a tenant's configuration answer different questions and are stored separately.

A user's preference (theme, density, default view) and a tenant's configuration (branding, policy, defaults) answer different questions and are stored separately, so an administrator changing a company setting doesn't overwrite what an individual chose. Collapsing the two is a common shortcut, and it produces a bug that's expensive to unpick later.

What shipped
  • User preferences and tenant configuration stored separately
  • An admin change can't overwrite an individual's choice
  • Avoids a merge that's expensive to unpick later

An operator console from the start

The vendor's own staff have the tenant, plan and log surfaces they need on day one, well before any incident.

The vendor's own staff get tenant, plan, subscription and activity-log surfaces from day one, before an incident makes them necessary. An operator console built during an incident is always built badly and in a hurry. Building it up front costs a fraction of that, and the first support question already has an answer.

What shipped
  • Tenant, plan, subscription and log consoles on day one
  • Built calmly, well before a first incident
  • The first support question already has a screen
Provision a tenant
Tenants › New · nothing is created until you confirm
120 tenants todayFind a tenant/Thu, Sep 17, 2026

New tenant

Step 1 of 1
Tenant nameBirchline Veterinary GroupSubdomainbirchlineAvailableLowercase letters, digits and hyphenschecked as typedNot a reserved name (admin, api, www, app…)checked as typedNot taken by another tenantchecked as typedPlan, chosen up frontStarter$295 seats · 10 GBGrowth$9925 seats · 100 GBScale$249100 seats · 500 GBEnterpriseCustomContracted ceilingsOwnerIvy CastellanosOwner emailivy@birchlinevet.comTrialNoneBilling startsOn creation
Logged as tenant.provisioned by ProvisionTenantCancelCreate tenant

Seeded before anything is created

Listed, not yet written
Everything the new tenant starts with, so nothing is left for the first support ticket.
RolesOwnerAdminBillingMember
Tenant settings · defaultsBrandingName and initials, no logo yetPassword policy12 characters minimumSession length12 hoursRequire second factorAdmins and OwnerData regionus-east
Owner's preferences · stored apartLocaleen-USNotificationsEmail for billing and securityThemeSystem
RecordsStorage prefixtenants/tn_1163/Activity logFirst entry: tenant.provisionedSubscriptionGrowth · starts on creation
Every seeded row carries the new tenant’s id from the first write
On screen

Provisioning a tenant: the subdomain checked as it's typed, the plan chosen up front, and every seeded default (roles, tenant settings, the owner's preferences and records) listed before anything is created.

Process

Phase by phase

  1. Phase 1: Tenancy

    Scoped Globally

    Built tenant isolation as a global scope that no query has to remember. The class of leak it prevents is the one that had already happened twice.

    • Tenant Model
    • Global Scope
    • Tenant Switching
  2. Phase 2: Plans

    Limits That Bite

    Built pricing plans whose limits are checked where the resource is created. A limit that only appears on a pricing page is marketing, and this one is a constraint.

    • Pricing Plans
    • Limit Enforcement
    • Plan Assignment
  3. Phase 3: The Activity Log

    Written By The Action

    Built logging into the layer that performs actions, so a new feature is audited by construction and nobody has to remember to call a logger.

    • Activity Log
    • Actor & Target
    • Log Browsing
  4. Phase 4: Preferences

    User, Not Tenant

    Kept per-user preferences distinct from tenant configuration, because conflating them is how one user's choice becomes everyone's setting.

    • User Preferences
    • Tenant Settings
    • Defaults
  5. Phase 5: The Operator Console

    For The Vendor's Own Staff

    Built the admin surface the vendor's team uses to create tenants, move plans and read the log: the part every one of the four products had left until last.

    • Tenant Admin
    • Plan Admin
    • Log Viewer
Tenants
120 tenants on one tenancy implementation · plans, seats and renewals
Provision tenantFind a tenant/Thu, Sep 17, 2026
Tenants
120
Active
113
On trial
4
Suspended
3
Plans
4 Starter to Enterprise
Any planAny statusSort: renewalShowing 11 of 120
TenantPlanSeatsStatusRenewsCeilings
HPHarbor & Pike ArchitectsharborpikeGrowth19 / 25ActiveOct 1Near a ceiling
CFCalloway FreightcallowayGrowth6 / 25ActiveOct 17Within plan
QHQuarry Hill ClinicquarryhillStarter5 / 5ActiveSep 29Near a ceiling
NSNorthfork SurveyingnorthforkScale64 / 100ActiveJan 8Within plan
BSBellweather StudiobellweatherGrowth4 / 25TrialTrial · Sep 24Within plan
LRLumen Row Co-oplumenrowStarter3 / 5SuspendedPast dueWithin plan
TLTidewater LegaltidewaterEnterprise212 / —contractedActiveMar 1Within plan
ODOxbow Dental PartnersoxbowScale41 / 100ActiveNov 12Within plan
GPGreywell Property MgmtgreywellGrowth23 / 25ActiveSep 22Near a ceiling
KCKiln & Co. CeramicskilncoStarter2 / 5ActiveOct 4Within plan
RHRedfern Home CareredfernGrowth11 / 25ActiveOct 26Within plan
Opening a tenant shows its usage against the plan, its members and the log for that tenant alone
On screen

The tenants list in the operator console: every tenant's plan, seats against the plan's ceiling, status and renewal, with the tenants near a ceiling flagged.

After the rebuild

1

Implementations of tenancy

None

Per-query tenant filters

All

Plan limits that are checked

0

Features that can skip the log

Implementations of tenancy is one. Per-query tenant filters is none: scoping is applied globally beneath the queries. Plan limits that are checked is all of them, enforced at creation. Features that can skip the log is zero, because the log is written by the layer that acts.

Client name withheld under NDA. Figures are approximate, drawn from the engagement’s own reporting.

What the rebuild taught us

  • Anything rebuilt for every product will eventually be built wrong under a deadline. Build it once.

    Anything rebuilt per product is eventually built wrong under a deadline, and the four implementations had four different failures for exactly that reason.

  • A plan limit rendered on a pricing page and never checked is a support ticket with a date on it.

    A limit that's displayed and never enforced is a support ticket with a date on it, and the date is whenever a customer first exceeds it.

  • Audit logging belongs in the layer that acts. Leave it to the caller and every new feature is a chance to skip it.

    Logging from the caller makes every new feature a chance to forget. Logging from the acting layer makes completeness a property of the system, no discipline required.

  • The operator console isn't a nice-to-have. It's the surface the vendor lives in, and it's always deferred.

    The operator console is where the vendor's own staff live, and it's always deferred, which is why it always gets built in a hurry during the first incident.

Ways of working

How we worked alongside the team

01
  1. 01

    We started from the vendor's own incident write-ups, with no feature list. Each of the five parts of the scaffold traces to a specific thing that had already gone wrong in one of the four products, which is why the scope stayed small enough to actually finish.

    Eight weeks, fixed, built as a scaffold instead of a library: the vendor starts products from it without depending on it, which suits a team that will inevitably need to diverge. The operator console was in scope from the start, because every previous product had built one during an incident and built it badly.

Four requests, one tenancy layer

A filter someone has to remember will be forgotten. A scope beneath the query can’t be.

The same board query with and without the global scope, a queue job that has no tenant, and a create over the API at the plan’s ceiling, each followed to the line the activity log gets. Switch tabs, or use the arrow keys once one is focused.

Scoping as a where clause each query adds holds until someone writes a query without one. Nothing is wrong with this code except what it left out, and so the board shows other tenants' projects.

  1. Code written
  2. SQL sent
  3. Rows returned
  4. Log
Livewire\ProjectBoard · before · tenant 1042 signed inIllustrative
// the filter this query forgotProject::where('status', 'active')    ->latest()->get();

What reaches MySQL

select * from projects where status = 'active' order by created_at desc

What the board shows

  • Harbor Street Clinic fit-outtenant 1042
  • Unit 4 lease surveytenant 0977
  • Lakeview townhomes, phase 2tenant 1042
  • Depot yard drainagetenant 1108
  • Quarterly fleet inspectiontenant 0977
  • Mill Road footbridgetenant 1108

4 of 6 rows belong to other tenants.

Activity log(nothing: logging was each caller's job, and this caller didn't)

Code, SQL, ids and log lines are illustrative, and so is the step timing. The mechanisms are the ones the scaffold ships: one global scope, limits checked where the resource is created, and a log written by the layer that acts.

Architecture

From any request to a row, through one tenancy layer

The parts that are easy to get subtly wrong sit beneath the features. A feature built on the scaffold is scoped, limited and logged just by existing there. Its author doesn't have to remember a thing.

  1. 01 · Trigger
    A web request, API call or jobEvery channel enters the same actions, so an API call can't bypass what the interface enforces.
  2. 02 · Isolation
    Global tenant scopeApplied once, beneath every query. An unscoped read has to be opted into and named.
  3. 03 · Engine
    The action, with its plan limitThe limit is checked server-side where the resource is created, and a refusal names the limit hit with the upgrade path.
  4. 04 · State
    MySQL: rows, settings, logThe activity log is written by the layer performing the action; user preferences and tenant settings are stored separately.
  5. 05 · Delivery
    Livewire operator consoleTenant, plan, subscription and log surfaces for the vendor's own staff from day one, well before any incident.

What a hurried feature can no longer do

Tenant isolation, enforced limits & a complete audit trail

One tenant can't read another's rows by omission

Tenant scoping is a global scope beneath every query, so there's no where clause for anyone to forget. An unscoped read has to be opted into and named, so a query written in a hurry can't leak by leaving something out.

A plan limit is a constraint, checked every time

Every limit is checked server-side where the resource is created. The refusal names the limit that was hit with the upgrade path attached, and an API call that skips the interface meets the same check.

No action, admin or otherwise, goes unrecorded

The activity log is written by the layer that performs the action, so a new feature is logged by existing. Operators get the log viewer from day one, so the first question after an incident has an answer.

Rebuilding tenants, plans and the audit log for every new product? Scope your build in 3 minutes.

Scope your build
Have a project?

Let's talk

Running a large platform, shaping a first MVP, or getting a product ready for a funding round? Tell us where you are. We'll shape the process around it, and stay with you after launch.