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.
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.
Modernizacja aplikacji PHP: bezpieczna droga od legacy do PHP 8.3
„Modernizacja aplikacji PHP” brzmi jak zadanie technologiczne. W działającym systemie biznesowym to zarządzanie ryzykiem: użytkownicy nadal potrzebują obecnych procesów, integracje oczekują dotychczasowych payloadów, a firmy nie można zatrzymać na czas przepisywania każdej klasy. Najbezpieczniejsza droga to seria obserwowalnych zmian, które ograniczają niewiadome przed zmianą architektury.
Efekt biznesowy to codebase, który zespół może zmieniać z mniejszą obawą: zachowanie jest testowalne, zależności są łatwiejsze do utrzymania, a każdy krok modernizacji można wdrożyć lub wycofać bez pełnego rewrite.
Zacznij od dowodów, nie od wyboru frameworka
Zanim wybierzesz Laravel, Symfony albo czysty PHP, opisz, co aplikacja naprawdę robi. Zapisz wersje PHP i rozszerzeń, ograniczenia Composera, punkty wejścia, zadania cykliczne, konsumentów kolejek, bazy danych, zewnętrzne API i założenia deploymentu. Poszukaj dynamicznych include, globalnego stanu, bezpośredniego SQL, zapisów do filesystemu oraz handlerów błędów zmieniających przepływ. Te szczegóły lepiej wyznaczają zakres migracji niż nazwa frameworka.
Projekt PHP stojący za DigiSpace działa na PHP 8.3 i Laravel 13 z MySQL 8. Publiczna strona używa Blade, panel administracyjny Inertia i Vue, a aplikacja integruje się z Sanctum, Sentry, MinIO, Zoho CRM i reCAPTCHA. Każda integracja jest granicą do sprawdzenia podczas aktualizacji: aplikacja może się skompilować, a mimo to zgubić webhook, upload albo granicę uwierzytelniania.
Uczyń obecne zachowanie widocznym
Legacy systems często nie mają testów dla zachowania, które jest najważniejsze. Dodaj characterization tests przed zmianą implementacji. Test request może utrwalić statusy, redirecty i błędy walidacji. Test integracyjny może zapisać kształt payloadu CRM albo uploadowanego pliku. Mały fixture bazy może chronić istotne zasady sortowania i filtrowania.
Takie testy nie twierdzą, że obecne zachowanie jest idealne. Pokazują, na czym biznes polega dziś. Gdy granica jest jawna, możesz zmieniać wnętrze i świadomie zdecydować, kiedy zachowanie powinno się zmienić.
public function test_service_search_preserves_the_public_contract(): void
{
Service::factory()->create([
'title' => 'Laravel Development',
'status' => 'active',
]);
$response = $this->get('/en/services/search?search=Laravel');
$response->assertOk()
->assertSee('Laravel Development');
}
Przykład jest celowo mały. Dobry characterization test chroni widoczny kontrakt i pozostaje zrozumiały po zmianie implementacji.
Zaktualizuj runtime przed pełnym redesignem
Aktualizacje runtime ujawniają założenia ukryte przez stare wersje. Przechodź na wspierane wersje w kontrolowanej gałęzi, uruchamiaj testy i analizę statyczną na każdym kroku, a deprecation notices naprawiaj zamiast je wyciszać. Polityka wsparcia PHP przypomina, że active support i security support mają ograniczony czas.
PHP 8.3 dostarcza narzędzi do jawnego opisywania granic, w tym typowanych class constants, klas readonly i silniejszych type declarations. Używaj ich tam, gdzie opisują istniejący invariant. Wprowadzenie ich wszędzie naraz tworzy churn; na granicach usług, DTO i konfiguracji ułatwiają znalezienie błędu.
Dodaj typowane granice wokół nietypowanego kodu
Modernizacja nie wymaga natychmiastowego zamienienia każdej legacy funkcji w idealny model domenowy. Wprowadź mały adapter z typowanym wejściem i wyjściem, a nowy kod oprzyj na tym kontrakcie. Adapter normalizuje wartości null, legacy arrays i błędy providera.
final readonly class PublishResult
{
public function __construct(
public string $externalId,
public bool $published,
) {}
}
interface ChannelPublisher
{
public function publish(Post $post): PublishResult;
}
W DigiSpace taki styl pasuje do rozdzielenia na controllers, form requests, models i services. Klasa taka jak ServiceSaveRequest sprawdza granicę, a serwis pracuje już ze zweryfikowanymi wartościami. Ulepszeniem nie jest liczba klas, lecz wiedza, gdzie odrzucany jest nieprawidłowy input.
Wymieniaj fragmenty, gdy stara ścieżka nadal działa
W dużym codebase strangler pattern jest zwykle bezpieczniejszy niż rewrite. Wybierz fragment biznesowy z jasnym inputem i outputem: endpoint wyszukiwania, import, adapter billingowy albo workflow administracyjny. Podłącz nową implementację za tym samym kontraktem, porównaj zachowanie i usuń starą ścieżkę dopiero po pracy w realnych warunkach.
To chroni również deployment. Feature flag, przełącznik trasy albo odwracalna konfiguracja pozwalają wrócić do starej implementacji podczas analizy problemu. Przełącznik powinien mieć właściciela i datę usunięcia; stałe dwie ścieżki są kolejną formą legacy.
Oddziel migrację danych od migracji kodu
Zmiana tabel i zachowania w jednym release utrudnia diagnozę. Najpierw stosuj additive schema changes: dodaj nullable column albo nową tabelę, wdroż kod czytający oba warianty, wykonaj kontrolowany backfill, a potem uczyń nowy wariant głównym. Starą kolumnę i compatibility code usuwaj później.
Ta sama zasada dotyczy integracji. Zapisz nowy payload obok starego, porównaj wynik w logach lub sandboxie i przełącz wywołanie providera dopiero po sprawdzeniu kontraktu. Sentry i strukturalne application logs są użyteczniejsze, gdy każdy krok zapisuje, która ścieżka obsłużyła żądanie.
Deployment jest częścią projektu
Zmodernizowana aplikacja nadal potrzebuje nudnego release path. DigiSpace działa lokalnie w Dockerze przez Laravel Sail i używa workflow z release symlinkiem. Release to nie samo kopiowanie plików: zależności, migracje, public assets, cache warming i aktualny symlink muszą się zgadzać. Rollback jest realny tylko wtedy, gdy poprzedni release i zgodny stan bazy są dostępne.
Przed cutover sprawdź ważne przepływy na release candidate: login, publiczne strony usług, formularze, uploady, kolejki, dostarczanie do CRM i sitemap. To tańsze niż odkrycie po release, że aktualizacja runtime zmieniła kolejność middleware albo ścieżkę assetu.
Co modernizacja daje biznesowi
Wartość narasta. Wspierany runtime ogranicza tarcie bezpieczeństwa i hostingu. Testy obniżają koszt zmiany procesu. Typowane granice wyjaśniają odpowiedzialność. Mniejsze release'y łatwiej wycofać. Nie trzeba twierdzić, że nowa architektura jest idealna; wystarczy zamienić ukryte założenia w jawne granice.
Kiedy rewrite jest uczciwą odpowiedzią
Modernizacja przyrostowa nie zawsze jest tańsza. Jeśli model danych jest błędny, runtime nie ma wsparcia, deployment jest niedostępny, a zachowania biznesowego nie da się odizolować, replacement może być uzasadniony. Nawet wtedy migruj capability po capability i zachowaj stary system jako źródło zachowania. Rewrite to strategia dostarczania, nie zgoda na odrzucenie tego, od czego zależą użytkownicy.
Kontekst usługi znajdziesz na stronie Tworzenie aplikacji w PHP. Jeśli wybierasz między upgrade'em, przyrostowym replacementem a nową aplikacją Laravel/Symfony, zacznij od runtime, integracji i procesów biznesowych, które nie mogą się zepsuć.
Architektura Symfony dla złożonych aplikacji PHP: moduły, Messenger i integracje
Symfony sprawdza się, gdy aplikacja PHP wyrasta poza zbiór kontrolerów i integracji. Framework daje zespołowi jawne granice — HTTP, walidację, serializację, komunikację i konfigurację — bez narzucania jednego kształtu całemu produktowi. Wartość pojawia się wtedy, gdy te granice pozostają czytelne po wielu latach działania systemu.
Efekt biznesowy to system, który przyjmuje złożoność bez zamieniania każdej zmiany w ryzykowny release: moduły domenowe pozostają zrozumiałe, wolna praca opuszcza request path, a zewnętrzne integracje można wymieniać za znanym kontraktem.
Wybierz Symfony według kształtu problemu
Mała aplikacja CRUD nie potrzebuje rozbudowanej architektury. Symfony ma sens, gdy system ma kilka domen biznesowych, długowieczne integracje, workflow asynchroniczne albo zespół chce używać komponentów poza pełnym monolitem. Decyzja powinna wynikać z tych ograniczeń, a nie z samej preferencji frameworka.
Publiczna aplikacja DigiSpace jest oparta na Laravel, dlatego ten tekst jest przewodnikiem technicznym, a nie opisem produkcyjnego repozytorium Symfony. Wzorce wynikają z oficjalnych komponentów Symfony i z tych samych problemów widocznych w naszej pracy z PHP: jawna walidacja requestów, cienkie kontrolery, testowalne serwisy, kolejki, zewnętrzne API i wdrożenia z możliwością rollbacku.
Niech warstwa HTTP będzie prosta
Kontroler Symfony powinien tłumaczyć HTTP request na application command, a wynik na HTTP response. Nie powinien decydować, jak liczyć fakturę, jak ponawiać wywołanie providera ani jak koordynować transakcję. Te decyzje należą do application services lub handlers, które można testować bez uruchamiania całego kernela.
final class CreateOrderController
{
public function __construct(
private CreateOrderHandler $handler,
) {}
public function __invoke(CreateOrderRequest $request): JsonResponse
{
$order = $this->handler->handle(
new CreateOrderCommand(
customerId: $request->customerId(),
lines: $request->lines(),
),
);
return new JsonResponse(['id' => $order->id()], Response::HTTP_CREATED);
}
}
Najważniejsza jest granica. Walidacja może odrzucić błędny input na brzegu, a handler pracuje z command o znanym kształcie. Dzięki temu późniejsze przejście z synchronicznego kontrolera na message handler jest mniej inwazyjne.
Organizuj kod wokół domen, nie folderów
Symfony nie narzuca jednego layoutu katalogów. W rosnącym systemie organizuj kod wokół możliwości biznesowych, takich jak Orders, Billing czy Catalog. Moduł może zawierać application commands, domain rules, infrastructure adapters i HTTP entry points. Ważniejsze od nazw folderów jest to, aby kierunek zależności był jasny.
Moduł domenowy nie powinien wiedzieć, czy email wysłał Symfony Mailer, worker kolejki czy test double. Powinien wyrazić event albo command. Infrastruktura decyduje, jak wiadomość podróżuje. Dzięki temu zmiana providera staje się ograniczonym zadaniem zamiast przeszukiwania wszystkich kontrolerów.
Użyj Messenger, gdy request powinien zakończyć się szybciej
Symfony Messenger obsługuje zarówno natychmiastowe przetwarzanie, jak i transporty wykonujące wiadomości później. Zespół może zacząć od synchronicznego message busa, a wybrane zadania przenieść do kolejki, gdy uzasadnia to latency lub sposób obsługi błędów. Message to mały, serializowalny opis pracy, a handler posiada side effect.
final readonly class GenerateInvoice
{
public function __construct(public string $invoiceId) {}
}
#[AsMessageHandler]
final class GenerateInvoiceHandler
{
public function __invoke(GenerateInvoice $message): void
{
// Pobierz fakturę, wyrenderuj PDF, zapisz go i powiadom użytkownika.
}
}
Praca w tle wymaga operational policy. Skonfiguruj retry dla przejściowych błędów providera, failure transport dla wiadomości, których nadal nie da się obsłużyć, oraz idempotency rule, aby ponowienie nie utworzyło podwójnej płatności ani powiadomienia. Kolejka jest mechanizmem dostarczenia, nie gwarancją bezpieczeństwa nieidempotentnej operacji.
Schowaj systemy zewnętrzne za adapterami
Symfony HttpClient daje spójną abstrakcję klienta, ale formaty odpowiedzi providera nie powinny przenikać do application code. Utwórz mały adapter mapujący request i response providera na własne value objects. Timeouty, uwierzytelnianie, retry policy i logging trzymaj w adapterze albo jego konfiguracji transportu.
Jest to szczególnie przydatne dla płatności, synchronizacji CRM i webhooków. Aplikacja może testować awarię providera bez jego wywoływania, a adapter może mieć osobny contract test z sandboxem. Gdy provider zmienia payload, najpierw zmienia się jedna granica.
Uczyń konfigurację i sekrety jawnymi
Długo działające systemy Symfony często psują się na konfiguracji: worker ma inne środowisko, komenda CLI nie widzi sekretu albo endpoint stagingowy trafia do produkcji. Traktuj konfigurację jak input aplikacji. Sprawdzaj wymagane wartości przy starcie, trzymaj sekrety poza repozytorium, a środowisko workera wpisz do checklisty wdrożenia.
Nie chowaj decyzji operacyjnych w static globals. Typowany configuration object albo injected parameter pokazuje zależność w konstruktorze i czyni założenia testu czytelnymi.
Testuj kontrakty, które mają znaczenie
Testy jednostkowe są przydatne dla domain rules, ale nie zastępują HTTP i integration tests. Praktyczny zestaw Symfony ma trzy poziomy: szybkie testy obliczeń i polityk, application tests dla command i handler behaviour oraz mniejszy zestaw testów HTTP dla routingu, walidacji i serializacji. Testy integracyjne powinny obejmować granice, na których można zgubić dane: kolejki, webhooki, bazy i klientów zewnętrznych.
Podczas modernizacji istniejącej aplikacji PHP characterization tests są bezpiecznym początkiem. Zapisz aktualną odpowiedź i side effects przed przeniesieniem kodu. Następnie wymieniaj jeden moduł lub integrację naraz i zostaw odwracalną trasę albo feature switch.
Planuj deployment z uwzględnieniem workerów i wiadomości
Wdrożenia Symfony mają szczegół, którego nie widać w zwykłym HTTP deploymencie: workery są długo działające. Nowy release może zawierać zmienioną klasę message albo handler, podczas gdy stary worker nadal ma załadowany poprzedni kod. Restartuj workery w ramach release, używaj kompatybilnych zmian wiadomości przy rolling deploy i obserwuj failure transport po aktywacji.
Migracje bazy powinny najpierw być addytywne. Dodaj kolumnę albo tabelę, wdroż kod czytający obie wersje, wykonaj backfill, a dopiero w kolejnym release usuń compatibility code. Dzięki temu rollback nie zależy od stanu bazy, którego już nie ma.
Co to podejście daje biznesowi
Modułowość obniża koszt pracy równoległej i wyjaśnia odpowiedzialność. Messenger usuwa wolną lub zawodną pracę z user request. Adaptery ograniczają zmianę providera. Jawna konfiguracja i restart workerów zmniejszają klasę incydentów „działa tylko w jednym środowisku”. To praktyczne korzyści, które nie wymagają obietnicy, że Symfony usunie całą złożoność.
Kiedy lepszy będzie Laravel albo same komponenty
Nie wybieraj Symfony automatycznie. Laravel może być szybszy dla produktu, który korzysta z jego konwencji i zintegrowanych narzędzi. Mniejszy system może potrzebować tylko komponentów Symfony — HttpClient, Messenger albo Serializer — bez całego frameworka. Właściwa odpowiedź to najmniejszy zestaw granic, który utrzyma reguły biznesowe w jasności, a ryzyka operacyjne w polu widzenia.
Opis usługi znajdziesz na stronie Tworzenie aplikacji w Symfony. Jeśli wybierasz między Symfony, Laravel albo pojedynczymi komponentami, zacznij od domen, trybów awarii integracji i cyklu życia workerów.
Kategorie
Najnowsze wpisy
Archiwum