DigiSpace
  • autor admin

Architektura Laravel Multi-Tenant SaaS: tenancy domenowe i izolacja PostgreSQL

Architektura Laravel Multi-Tenant SaaS: tenancy domenowe i izolacja PostgreSQL

Wielodostępność łatwo opisać, ale trudno zbudować ją bezpiecznie. Jedna aplikacja obsługuje wiele organizacji, każda oczekuje własnych użytkowników i danych, a właściciel platformy nadal potrzebuje centralnego miejsca do rozliczeń, wsparcia i administracji. Rozpoznanie domeny to dopiero pierwszy krok. Najtrudniejsze jest utrzymanie poprawnego kontekstu tenanta w żądaniach HTTP, uwierzytelnianiu, kolejkach, zdarzeniach i usługach działających w tle.

Efekt biznesowy to kontrolowany wzrost: jeden produkt może obsługiwać wiele klinik, ich dane operacyjne pozostają oddzielone, onboarding jest powtarzalny, a zespół rozwija platformę bez osobnej bazy kodu dla każdego klienta.

Zacznij od granicy, nie od kolumny w tabeli

VetSpace to platforma dla praktyk weterynaryjnych z centralnym panelem właściciela i aplikacją konkretnej kliniki. Klinika otrzymuje własną domenę, personel pracuje w jej kontekście, a właściciele zwierząt korzystają ze wspólnej powierzchni centralnej. To coś więcej niż dodanie tenant_id do kilku tabel. Aplikacja musi określić kontekst żądania, zanim odczyta dane.

Projekt używa Laravel 13, PostgreSQL i stancl/tenancy. Domeny tenantów reprezentuje osobny model Domain, a Tenant korzysta z database- i domain-concerns pakietu. Model tenanta przenosi więc zarówno tożsamość biznesową, jak i dane połączenia potrzebne do inicjalizacji właściwego kontekstu.

Jedna aplikacja, dwa konteksty API

VetSpace przechowuje trasy centralne i tenantowe w jednej aplikacji Laravel, ale pełnią one różne funkcje. Centralne API obsługuje konta, zwierzęta, zamówienia, katalog klinik i subskrypcje. Trasy tenantowe obsługują oddziały, usługi, lekarzy, gabinety, wizyty i opinie klinik.

Rozdział widać w plikach tras, a nie w jednym dużym kontrolerze. TenancyServiceProvider ładuje trasy tenantowe, a tenant_api.php inicjalizuje domenę przed wejściem do kontrolera. Zasada jest prosta: endpoint centralny czyta dane centralne, endpoint kliniki działa w kontekście bazy tej kliniki.

Architektura multi-tenant VetSpace: centralne i tenantowe API rozpoznawane przez domenę

Dopasuj izolację do planu

Platforma nie traktuje każdego tenanta tak samo. Architektura obsługuje osobny schemat PostgreSQL albo bazę dla tenanta, a plan określa wymagany poziom izolacji. Wspólna baza z osobnymi schematami upraszcza obsługę mniejszych tenantów. Osobna baza daje klientowi enterprise silniejszą granicę oraz prostszą odpowiedź w sprawie backupu, odtwarzania i lokalizacji danych.

Izolacja ma koszt. Większa oznacza więcej pracy z połączeniami, migracjami i monitoringiem. Mniejsza ogranicza narzut, ale zwiększa znaczenie scope'owania zapytań i procedur odtwarzania. Właściwe rozwiązanie powinno odpowiadać wymaganiom produktu, wsparcia i recovery; „database per tenant” nie zawsze jest najlepszą odpowiedzią.

Porównaj warianty izolacji

Popularne projekty Laravel używają wspólnych tabel z kluczem tenanta, osobnych schematów PostgreSQL albo osobnych baz. Wspólne tabele są proste operacyjnie, lecz każda granica zapytania musi być poprawna. Schematy dodają przestrzeń nazw na poziomie bazy, zachowując jedną instalację PostgreSQL. Osobne bazy wzmacniają izolację i odtwarzanie konkretnego tenanta, ale zwiększają pracę z połączeniami, migracjami i monitoringiem.

W VetSpace wybór zależy od planu produktu. Platforma nie ponosi kosztu izolacji enterprise dla każdej małej kliniki, ale ma silniejszą opcję, gdy wymaga tego biznes. To decyzja produktowa wyrażona przez infrastrukturę, a nie domyślna konfiguracja pakietu.

Kolejność middleware jest częścią modelu bezpieczeństwa

W tenantowym API uwierzytelnianie nie może być niezależnym pierwszym krokiem. Tenant musi zostać rozpoznany, zanim aplikacja dowie się, z której bazy pobrać użytkownika lub token. Projekt jawnie dodaje InitializeTenancyByDomain do priorytetu middleware w TenancyServiceProvider.

protected function makeTenancyMiddlewareHighestPriority(): void
{
    $tenancyMiddleware = [
        Middleware\PreventAccessFromCentralDomains::class,
        Middleware\InitializeTenancyByDomain::class,
        Middleware\InitializeTenancyBySubdomain::class,
    ];

    foreach (array_reverse($tenancyMiddleware) as $middleware) {
        $this->app[Kernel::class]
            ->prependToMiddlewarePriority($middleware);
    }
}

Definicja trasy pokazuje, dlaczego to ma znaczenie: nawet jeśli auth:sanctum znajduje się wcześniej w tablicy, lista priorytetów Laravel uruchomi inicjalizację domeny jako pierwszą. Taki szczegół zasługuje na test i komentarz, bo późniejsze „uproszczenie” trasy może zmienić granicę danych.

Utworzenie tenanta to cykl życia, nie insert

Utworzenie tenanta uruchamia pipeline: baza zostaje utworzona, wykonywane są migracje tenantowe, dane są seedowane, powstaje główny oddział i kopiowany jest właściciel. Usunięcie uruchamia odpowiednie czyszczenie bazy. Zebrane kroki czynią onboarding powtarzalnym i dają jedno miejsce do obsługi częściowych błędów.

JobPipeline::make([
    Jobs\CreateDatabase::class,
    Jobs\MigrateDatabase::class,
    Jobs\SeedDatabase::class,
    CreateMainBranch::class,
    ReplicateOwnerToTenant::class,
])->send(fn (Events\TenantCreated $event) => $event->tenant)
  ->shouldBeQueued(false);

Na tej granicy pipeline jest celowo synchroniczny. Utworzenie kliniki nie powinno zgłaszać sukcesu, gdy brakuje jej tabel albo pierwszego oddziału. Pozostałą pracę można zakolejkować po uzyskaniu poprawnego stanu.

Joby w tle muszą przenosić kontekst tenanta

Joby pokazują, czy multi-tenancy działa operacyjnie. RunAiAnalysis zapisuje ID tenanta i analizy, znajduje tenanta w kontekście centralnym, inicjalizuje tenancy, wykonuje analizę i kończy kontekst w bloku finally. Ma to znaczenie dla długo działającego workera, który obsługuje wielu tenantów.

try {
    tenancy()->initialize($tenant);
    $analysis = AiAnalysis::find($this->analysisId);
    $service->analyze($analysis);
} finally {
    if (tenancy()->initialized) {
        tenancy()->end();
    }
}

Ta sama zasada dotyczy jobów kopiujących użytkowników, tworzących główny oddział i synchronizujących VetCard. Payload zawierający tylko ID rekordu nie wystarczy, jeśli ID ma znaczenie wyłącznie w jednej bazie tenantowej.

Rekordy centralne też potrzebują drugiej granicy

Nie każdy rekord należy wyłącznie do jednej kliniki. Właściciele i zwierzęta żyją w przestrzeni centralnej i mogą być powiązani z wieloma klinikami przez tabele pivot. VetSpace używa traitu ScopesToClinic, aby ograniczyć odczyty i zapisy do ID klinik użytkownika, opcjonalnie z oddziałem. Chroni to przed IDOR nawet wtedy, gdy model jest centralny.

Łatwo przeoczyć tę różnicę: izolacja bazy chroni tabele tenantowe, a scope'owanie relacji chroni rekordy wspólne. Platforma, na której właściciel może odwiedzać wiele klinik, potrzebuje obu granic.

Co ta architektura daje biznesowi

Wartością nie jest liczba pakietów Laravel w composer.json. Chodzi o możliwość dodania kliniki bez kopiowania aplikacji, dopasowanie izolacji do planu oraz połączenie centralnego billing z operacjami tenantów bez mieszania danych. Ryzyka stają się jawne: migracje tenantów, kontekst kolejki, routing domen, backup i dostęp między klinikami są częścią projektu.

Kiedy to podejście jest zbyt drogie

Małe narzędzie wewnętrzne dla jednej organizacji nie potrzebuje domain-based tenancy, menedżerów baz tenantów ani pipeline'u onboardingu. Prostemu produktowi członkowskiemu może wystarczyć jedna baza z poprawnym row scoping. Multi-tenancy uzasadnia złożoność, gdy wiele organizacji dzieli produkt, a ryzyko wycieku danych, zduplikowanych wdrożeń i ręcznego onboardingu kosztuje więcej niż narzut infrastruktury.

Jeśli granica jest realna, zaprojektuj ją wcześnie. Opisz domeny, wymagania izolacji danych i onboarding, a wyznaczymy najmniejszą architekturę, która zachowa te granice.

Kontekst usługi znajdziesz na stronie Tworzenie aplikacji w Laravel.

Share this post