← Back to workCase study 02 / 03

From team-level to store-level multi-tenancy

Moving the tenant boundary of a Shopify data platform from the account to the individual store, and what it cost to learn that “no errors” is not “no data lost”.

  • Shop

    the new tenant, replacing team, for API calls, sync, bulk operations and broadcasts

  • 2

    panels with role and permission management: system and tenant

  • 0

    orders can now be saved without a shop: enforced in the database and at import

01 —Context

Risify began with one store per account. A team owned a Shopify store, and everything hung off the team: the store’s credentials, sync jobs, bulk operations and the real-time events the dashboards listen to. Accounts with more than one store needed each store to be its own data under one account, and needed control over who could do what.

02 —Problem

The tenant boundary was in the wrong place, and moving it touches every query and every background job. The risks were concrete: data showing up under the wrong store, a sync job running with another store’s credentials, an event reaching listeners it was not meant for, or two stores’ scheduled jobs blocking each other.

03 —What I did

I made the store, not the team, the tenant, and moved in small steps so the old and new paths could coexist until nothing read the old one.

  • Scoping first: added a shop reference to every model that had only been scoped by team (vendors, product tags, suppliers and others), and to pivot tables.
  • Then the API: moved every Shopify API client call from the Team model to the Shop model, so credentials live with the store they belong to.
  • Then the moving parts: scoped sync functions, bulk operations, import strategies and webhooks to the shop, and fixed scheduler mutex collisions between tasks for different teams.
  • Broadcasts: environment-prefixed channels, shop-level filtering of events, and a broadcast driver that fails safe, so a WebSocket outage cannot fail the request that raised the event.
  • Access: role and permission management in both the system and tenant panels with dynamic permission categories, a panel selector filtered by the user’s permissions, sync features restricted for disconnected shops, and soft delete for shops and teams.
  • Removed the legacy store columns from teams last, once nothing depended on them, and added a system diagnostics page.

04 —Result

Multi-store support shipped in April 2026, together with a stack upgrade to Laravel 12 and Filament 4. Each store’s data, sync and real-time events are isolated, and roles are managed in the product.

One thing slipped through. Six months later, while reconciling reports against Shopify, I found that the change had left a batch of orders with no shop attached. Nothing had failed loudly: the orders were simply invisible to every dashboard, which hid a large part of two months of revenue. The repair tool meant for exactly this case could not do its job, because it never looked at the orders table. I fixed the tool, repaired the data, and made an order without a shop impossible to save, both in the database and at import.

05 —What I’d do differently

Make the new tenant key required in the database on day one, not as a follow-up. A migration that changes who owns a row should ship with a check that counts orphaned rows and fails loudly; a clean error log only proves that nothing threw.

I would also have had the reports reconciled against Shopify per store before the change, not after. That check is what eventually found the problem, and it would have found it months sooner.