Po co w ogóle zarządzać schematem bazy w Doctrine?
Skutki ręcznego zarządzania SQL-em w projekcie
Pierwszy etap większości projektów wygląda podobnie: baza danych powstaje ręcznie, bez procedur i bez migracji. Kilka skryptów SQL w katalogu sql/, kilka zrzutów z phpMyAdmina i notatka „uruchom to na bazie”. Taki model jeszcze działa, dopóki w projekcie jest jedna osoba i jedno środowisko. W momencie, gdy dołącza kolejny deweloper albo pojawia się środowisko staging, baza zaczyna „dryfować”.
Najczęstsze objawy takiego dryfu schematu:
- inne tabele lub brakujące kolumny na różnych środowiskach (dev/stage/prod),
- nieudokumentowane zmiany schematu wykonane „na szybko” na produkcji,
- konflikty przy odtwarzaniu bazy z backupu („czemu tu jest inny typ kolumny?”),
- niemożność powrotu do poprzedniej wersji aplikacji, bo baza jest już „za bardzo zmieniona”.
Taki chaos skutkuje realnymi kosztami: dłuższym wdrażaniem funkcji, nieprzewidywalnymi błędami na produkcji i strachem przed każdą większą zmianą w modelu danych. Do tego dochodzi utrata wiedzy – osoby, które kiedyś ręcznie zmieniały strukturę, odchodzą z projektu, a z nimi znika kontekst decyzji.
Zarządzanie schematem bazy danych w Doctrine pozwala zamienić ten chaos w powtarzalny, zautomatyzowany proces. Zmiana w modelu danych nie jest już jednorazowym SQL-em, tylko wersjonowaną migracją, którą można uruchomić tak samo na każdym środowisku, w kontrolowanym momencie i z pełną historią.
Migracje jako element procesu wytwarzania oprogramowania
Migracje Doctrine nie są dodatkiem „do dorobienia później”. W dojrzałym projekcie stanowią integralną część procesu developmentu, tak samo jak testy jednostkowe czy code review. Jeśli zmieniasz encję, naturalną konsekwencją jest aktualizacja schematu bazy – a to właśnie robi migracja.
Dobrze zorganizowany proces zwykle wygląda tak:
- zmiana encji (dodanie pola, zmiana relacji),
- wygenerowanie lub ręczne napisanie migracji,
- lokalne uruchomienie migracji i testów,
- code review PR-a, razem z przeglądem klasy migracji,
- uruchomienie migracji przez CI na środowisku testowym,
- wdrożenie aplikacji i migracji na produkcji według jasnej procedury.
Z takim podejściem baza danych przestaje być „czarną skrzynką”, a każda zmiana w schemacie jest widoczna w historii repozytorium. Można sprawdzić, kiedy i dlaczego powstała dana kolumna, kto ją zmienił i jak wyglądał kod migracji.
Jak Doctrine spina encje z fizyczną strukturą bazy
Doctrine traktuje encje jako model domenowy, a bazę danych jako warstwę trwałości. Łącznikiem między tymi światami jest metadana (mapping) – definicja, w jaki sposób pola klasy odpowiadają kolumnom tabel, a relacje obiektów relacjom w bazie.
Z punktu widzenia zarządzania schematem kluczowe są trzy elementy:
- metadata encji – opis struktury, typów, indeksów, relacji,
- generator schematu (schema-tool) – narzędzie, które na podstawie metadata potrafi wygenerować SQL tworzący tabele,
- migracje Doctrine – warstwa nad DBAL, która zapisuje zmiany schematu w postaci klas „wersjonujących” bazę.
Doctrine zna więc docelowy model (na podstawie encji) i aktualny stan bazy (odczytywany przez DBAL). Na tej podstawie potrafi wygenerować różnicę – czyli zestaw zmian, które trzeba wykonać, aby doprowadzić bazę do zgodności z encjami. Ta różnica staje się treścią migracji.
„Schema-first” vs „code-first” w kontekście Doctrine
Sposób pracy nad schematem można uprościć do dwóch głównych stylów:
- schema-first – najpierw projektujesz bazę (np. diagramy, SQL), potem dopasowujesz do niej encje,
- code-first – najpierw modelujesz encje, a baza jest ich projekcją, generowaną narzędziami Doctrine.
Doctrine zdecydowanie faworyzuje podejście code-first. Źródłem prawdy jest wtedy kod encji, a nie ręczne skrypty SQL. Migracje powstają na podstawie różnic między modelem obiektowym a schematem bazy. To nie znaczy, że schema-first jest niemożliwe, ale wymaga ciągłego dopasowywania mappingu encji do ręcznie projektowanej bazy i częściej wymusza ręczne migracje.
W praktyce w wielu projektach pojawia się mieszanka – encje są głównym modelem, ale zespół nadal utrzymuje pewien poziom „świadomego projektowania” bazy (np. osobne migracje dla krytycznych indeksów, ręczne dostrajanie typów kolumn, customowe constrainty). Kluczem jest spójność: jeden, jasno zdefiniowany kierunek przepływu zmian (najczęściej: encje → migracje → baza).

Podstawowe pojęcia: encja, metadata, schema, migracja
Encja jako opis modelu domeny
Encja w Doctrine to zwykła klasa PHP reprezentująca obiekt biznesowy (np. User, Order, Product), rozszerzona o deklaracje mapowania. Te deklaracje mogą być zapisane jako:
- adnotacje w komentarzach PHPDoc,
- atrybuty PHP 8 (częściej używane w nowych projektach),
- zewnętrzne pliki XML lub YAML.
Przykład prostej encji z atrybutami:
<?php
use DoctrineORMMapping as ORM;
#[ORMEntity]
#[ORMTable(name: "users")]
class User
{
#[ORMId]
#[ORMGeneratedValue]
#[ORMColumn(type: "integer")]
private int $id;
#[ORMColumn(type: "string", length: 180, unique: true)]
private string $email;
#[ORMColumn(type: "string", length: 255)]
private string $password;
#[ORMColumn(type: "datetime_immutable")]
private DateTimeImmutable $createdAt;
// gettery/settery...
}
Na tej podstawie Doctrine wie, że potrzebna jest tabela users z odpowiednimi kolumnami i ograniczeniami (klucz główny, unikalny email, typy danych itp.).
Metadata a odwzorowanie w bazie danych
Metadata encji to zebrane informacje o:
- nazwie tabeli (
#[ORMTable]), - polach i ich typach (
#[ORMColumn]), - relacjach (
#[ORMManyToOne],#[ORMOneToMany],#[ORMManyToMany]), - indeksach i kluczach unikalnych (
#[ORMIndex],#[ORMUniqueConstraint]), - strategiach generowania kluczy głównych itp.
Z tych danych Doctrine buduje wewnętrzną reprezentację schematu, którą później wykorzystuje schema tool i system migracji. To dlatego zmiana atrybutu w encji, np. dodanie nullable: true lub unique: true, powinna wywołać aktualizację schematu bazy. Inaczej kod i baza zaczną się rozjeżdżać.
Migawka schematu i diff Doctrine
Żeby wygenerować migrację, Doctrine musi znać:
- aktualny stan schematu bazy,
- docelowy stan wynikający z encji.
„Migawką” schematu jest tu po prostu odczyt istniejących tabel i kolumn przez warstwę DBAL (DESCRIBE TABLE, SELECT z systemowych tabel katalogowych itp.). Ten odczyt Doctrine porównuje z tym, co wynika z metadata encji. Następnie generuje SQL, który przekształci migawkę aktualną w docelową.
Kluczowe jest zrozumienie, że:
- migawka to czytany stan bazy w danym momencie,
- Doctrine nie przechowuje gdzieś „idealnego” schematu niezależnie od bazy – za źródło prawdy służy mapping encji,
- różnica (diff) jest chwilowa – po jej wykonaniu migawka i mapping powinny być spójne.
Migracja jako skrypt transformacji schematu
Migracja Doctrine to klasa PHP dziedzicząca zazwyczaj po DoctrineMigrationsAbstractMigration, zawierająca metody up() i opcjonalnie down(). Jej rolą jest przekształcenie bazy z jednej wersji w następną w sposób powtarzalny.
Bardzo uproszczony przykład migracji:
<?php
declare(strict_types=1);
use DoctrineDBALSchemaSchema;
use DoctrineMigrationsAbstractMigration;
final class Version20240101090000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Dodanie kolumny last_login do tabeli users';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE users ADD last_login DATETIME DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE users DROP last_login');
}
}
Uruchomienie up() nadaje bazie nową wersję. Zwykle migracje są oznaczane numerami (timestamp lub sekwencja), a tabela doctrine_migration_versions przechowuje listę wykonanych migracji. To właśnie te informacje wykorzystuje komenda migrations:migrate, aby ustalić, co jeszcze trzeba uruchomić na danym środowisku.
Konfiguracja Doctrine i migracji od zera
Minimalna konfiguracja Doctrine ORM i DBAL
Doctrine można używać samodzielnie (czysty PHP) albo w ramach frameworka (najczęściej Symfony). Konfiguracja różni się warstwą „oprawy”, ale istota pozostaje ta sama: trzeba skonfigurować połączenie DBAL i menedżera encji ORM.
Przykład minimalnej konfiguracji w projekcie bez frameworka:
<?php
use DoctrineORMORMSetup;
use DoctrineORMEntityManager;
use DoctrineDBALDriverManager;
require __DIR__ . '/vendor/autoload.php';
$config = ORMSetup::createAttributeMetadataConfiguration(
paths: [__DIR__ . '/src/Entity'],
isDevMode: true
);
$connectionParams = [
'dbname' => 'app',
'user' => 'user',
'password' => 'secret',
'host' => '127.0.0.1',
'driver' => 'pdo_mysql',
];
$connection = DriverManager::getConnection($connectionParams, $config);
$entityManager = new EntityManager($connection, $config);
W Symfony większość tej konfiguracji jest generowana automatycznie przez pakiet doctrine/doctrine-bundle. Wystarczy skonfigurować parametry połączenia w .env i pliku config/packages/doctrine.yaml, a narzędzie CLI otrzymuje kompletne informacje o encjach i schemacie.
Instalacja i podstawowy setup doctrine/migrations
Do pracy z migracjami potrzebny jest osobny pakiet:
composer require doctrine/migrations
W projektach bez frameworka należy utworzyć plik konfiguracyjny, np. migrations.php:
<?php
use DoctrineMigrationsConfigurationConfiguration;
use DoctrineMigrationsDependencyFactory;
use DoctrineMigrationsConfigurationMigrationPhpFile;
$config = new PhpFile(__DIR__ . '/migrations.php'); // plik konfiguracyjny
$dependencyFactory = DependencyFactory::fromEntityManager(
$config,
new ExistingEntityManager($entityManager)
);
W Symfony konfiguracja jest uproszczona i sprowadza się do wpisów w config/packages/doctrine_migrations.yaml, np.:
doctrine_migrations:
migrations_paths:
'AppMigrations': '%kernel.project_dir%/migrations'
enable_profiler: '%kernel.debug%'Najważniejsze decyzje na starcie:
- namespace migracji (np.
AppMigrations), - katalog na pliki migracji (np.
migrations/w katalogu głównym), - konfiguracja tabeli wersji migracji (domyślnie
doctrine_migration_versions).
Schema-tool vs komendy migracji
Doctrine dostarcza dwie grupy narzędzi:
doctrine:schema:*– operacje bezpośrednio na schemacie (tworzenie, aktualizacja, usuwanie tabel),doctrine:migrations:*– operacje na wersjach migracji (tworzenie klas migracji, uruchamianie ich, cofanie).
Różnica jest zasadnicza:
- schema-tool działa „jednorazowo” – generuje aktualny SQL na podstawie encji i od razu aplikuję go na bazę (bez historii),
- migracje zapisują każdy krok w wersjonowanej klasie, dzięki czemu można odtwarzać, powtarzać i audytować historię zmian.
W nowych projektach schema-tool ma sens tylko przy tworzeniu zupełnie pierwszej bazy (greenfield). Później głównym narzędziem powinny być wyłącznie migracje, a schema-tool:update --force nie powinno być używane na żywych środowiskach.
Konfiguracja połączeń dla różnych środowisk
Zarządzanie schematem bazy danych w Doctrine jest bezpieczne tylko wtedy, gdy połączenia do baz są poprawnie odseparowane per środowisko. Najczęstszy błąd: lokalne środowisko dewelopera przypięte przez pomyłkę do produkcji i odpalenie tam migracji testowych.
Kilka zasad praktycznych:
- każde środowisko (dev, test, staging, prod) ma osobny URL połączenia – również wtedy, gdy fizycznie jest to ten sam serwer bazy (inne nazwy baz),
- dane dostępowe do produkcji są przechowywane wyłącznie w zmiennych środowiskowych na serwerze, nigdy w repozytorium,
- na lokalnych
.env.locallub odpowiednikach nie pojawiają się hasła do produkcji ani stagingu, - CLI dokłada wyraźny prefix/label w promptach (np. przez aliasy w
bashrc), żeby nie dało się pomylić kontekstu.
Przydatna praktyka to zdefiniowanie osobnych komend lub aliasów do migracji na różne środowiska, np. bin/console d:m:m --env=dev, --env=staging, --env=prod, oraz wymaganie dodatkowego potwierdzenia przy produkcji. W projektach z wieloma bazami (np. osobna baza do raportowania) warto jawnie wskazywać connection name w konfiguracji migracji, a nie polegać na domyślnej.
Drugim elementem bezpieczeństwa jest polityka uprawnień. Użytkownik bazy używany przez aplikację (i migracje) powinien mieć tylko takie prawa, jakie są niezbędne: na produkcji zwykle SELECT/INSERT/UPDATE/DELETE i DDL ograniczone do schematu aplikacyjnego. Konta administracyjne z pełnym DROP DATABASE nie powinny być używane do migracji z automatu ani zapisane w żadnym pliku konfiguracyjnym.
W większych zespołach opłaca się dodać dodatkową warstwę zabezpieczenia: np. pipeline CI/CD, który przed odpaleniem migracji na stagingu lub produkcji wymaga akceptacji (manual job) i prezentuje diff SQL. Pozwala to wychwycić pomyłki typu niezamierzony DROP COLUMN czy zmiana typu liczbowego na zbyt wąski, zanim uderzą w realne dane.
Dobrze poukładany proces migracji w Doctrine – od poprawnej konfiguracji połączeń, przez świadome generowanie diffów, po kontrolowane wdrażanie – sprawia, że zmiany w schemacie przestają być ryzykowną „sztuką dla sztuki”, a stają się zwykłym elementem cyklu developmentu, który da się powtarzać i przewidywać także wtedy, gdy aplikacja i baza urosną kilkukrotnie.

Pierwsza migracja dla istniejącej bazy (brownfield)
Częsty scenariusz: projekt korzysta z istniejącej bazy danych, a docelowo całość ma być zarządzana przez Doctrine ORM i migracje. Trzeba wtedy zbudować „punkt startowy” – stworzyć mapping encji, uzgodnić go ze schematem i wygenerować pierwszą migrację, która nie będzie niczego zmieniać na produkcji, a jedynie ustawi wersję bazową.
Synchronizacja encji ze schematem zastanym
Pierwszy krok to dostępny schemat bazy i faktyczne dane. Doctrine traktuje mapping encji jako definicję docelową, więc jeśli mapping nie odpowiada rzeczywistości, narzędzia migracji zaczną planować destrukcyjne zmiany. Dlatego migracja na istniejącą bazę powinna zacząć się od dopasowania encji do aktualnego stanu tabel, a nie odwrotnie.
Typowy przebieg:
- Wygenerowanie encji „odgórnie” (na podstawie wiedzy o domenie) albo „oddolnie” (na podstawie reverse engineering schematu).
- Ręczne dostosowanie mappingu (nazwy pól, długości, typy, nullable, relacje) tak, aby diff między encjami a bazą był zerowy.
- Dopiero po tym wygenerowanie „pustej” migracji startowej, która ustanowi wersję początkową.
Reverse engineering można przeprowadzić np. komendą:
bin/console doctrine:mapping:import "AppEntity" annotation --path=src/Entity
albo odpowiednikiem dla atrybutów/mappingu XML. Taki import daje tylko punkt wyjścia; mapping zwykle wymaga dopieszczenia pod kątem typów (np. ENUM, precyzja liczb), indeksów, relacji i nazw klas.
Tworzenie migracji bazowej bez zmian w schemacie
Migracja bazowa ma powiedzieć Doctrine: „od tej wersji uważamy, że schemat jest spójny z encjami”. Sama migracja nie powinna niczego modyfikować, jeśli baza jest już poprawnie zdefiniowana i ma dane produkcyjne.
Jedno z bezpieczniejszych podejść:
- Na kopii bazy (np. zrzut produkcji przywrócony lokalnie) uruchomić:
bin/console doctrine:migrations:diffi sprawdzić, czy wygenerowany SQL jest pusty lub minimalny (np. tylko tworzenie tabeli wersji migracji).
- Jeśli diff proponuje zmiany, poprawić mapping encji tak, aby odzwierciedlał schemat faktycznie używany.
- Gdy diff jest pusty, można ręcznie utworzyć pierwszą migrację, która zawiera tylko utworzenie tabeli wersji migracji (albo nawet pustą metodę
up()).
Przykładowa migracja bazowa:
<?php
declare(strict_types=1);
use DoctrineDBALSchemaSchema;
use DoctrineMigrationsAbstractMigration;
final class Version20240102000000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Migracja bazowa – uzgodnienie stanu istniejącej bazy z mappingiem Doctrine';
}
public function up(Schema $schema): void
{
// Brak zmian w tabelach domenowych – przyjmujemy istniejący schemat jako punkt startowy.
}
public function down(Schema $schema): void
{
// Zwykle pozostawia się puste – nie ma sensownego "cofnięcia" do stanu sprzed wdrożenia ORM.
}
}
Taka migracja zostanie wpisana do tabeli wersji i od tej pory wszystkie kolejne zmiany będą już w pełni kontrolowane przez mechanizm migracji.
Unikanie destrukcyjnych zmian przy pierwszym diffie
Największe ryzyko przy przejściu z „gołej” bazy na Doctrine to niespodziewane DROP TABLE lub DROP COLUMN w pierwszej migracji. Mechanizm diffu działa mechanicznie: jeśli tabela lub kolumna nie jest odwzorowana w mappingu, traktuje ją jako zbędną.
Jeśli część tabel jest „techniczna” lub obsługiwana przez inne systemy, można:
- nie mapować ich w ogóle (Doctrine będzie je ignorować, ale diff może chcieć je usuwać),
- użyć opcji ignorowania niektórych tabel w konfiguracji migracji (np. przez
schema_filterw DBAL lub odpowiednie filtry w narzędziu CLI), - wyciągnąć potencjalnie destrukcyjne
DROP ...z wygenerowanej migracji i omówić je z DBA, jeśli faktycznie mają zostać wykonane.
Dobrym nawykiem jest ustawienie w pipeline CI mechanizmu, który „blokuje” migracje zawierające DROP TABLE lub DROP DATABASE bez dodatkowej akceptacji. W wielu projektach taka bariera uratowała już istniejące dane przed zwykłym błędem konfiguracji mappingu.
Codzienny workflow z migracjami w zespole
Gdy podstawy są ustawione, największe znaczenie ma powtarzalny sposób pracy. Chodzi o to, aby każdy deweloper wytwarzał migracje w podobny sposób, a pipeline dostarczał przewidywalny proces od commita do wdrożenia.
Typowy cykl pracy dewelopera
Dla pojedynczej zmiany w modelu domenowym cykl jest zwykle bardzo podobny:
- Modyfikacja encji (nowe pole, relacja, zmiana typu).
- Uruchomienie:
bin/console doctrine:migrations:diffw kontekście lokalnej bazy powiązanej z danym branchem.
- Przegląd wygenerowanej migracji, ewentualna korekta SQL (np. dopisanie
USINGprzy konwersji typów, rekalkulacja danych, wypełnienie wartości domyślnych). - Uruchomienie:
bin/console doctrine:migrations:migratelokalnie, sprawdzenie, czy testy przechodzą i aplikacja działa na nowym schemacie.
- Commit encji + migracji w jednym zestawie zmian (jeden branch, jeden pull request).
Gdy migracja wprowadza bardziej złożone modyfikacje, warto dodać osobne testy integracyjne, które sprawdzą zachowanie na świeżej bazie i przy aktualizacji bazy z danymi testowymi.
Synchronizacja schematu między deweloperami
Źródłem prawdy dla schematu są migracje w repozytorium. Kiedy jeden deweloper doda nową migrację, pozostali powinni ją po prostu uruchomić. W praktyce oznacza to prosty rytuał po zaciągnięciu zmian:
git pull
bin/console doctrine:migrations:migrate --no-interaction
Jeśli projekt używa narzędzi takich jak Makefile lub skrypty composer, wygodnie jest mieć skrót:
composer db:migrate
który zawsze odpala komplet potrzebnych kroków (np. clear cache, migracje, seed danych). W mikroserwisach z wieloma bazami dobrze jest dodać osobne komendy na każdy serwis.
Konflikty migracji i numeracji wersji
Przy timestampach w nazwach migracji naturalnym konfliktem jest sytuacja, gdy dwóch deweloperów w tym samym czasie wygeneruje migracje, a kolejność ich wykonania ma znaczenie. Doctrine sam potrafi uporządkować migracje po numerze wersji, ale konflikty logiczne (np. dwie migracje zmieniają tę samą kolumnę) trzeba rozwiązać ręcznie.
Kilka zasad, które ograniczają problemy:
- nie edytować istniejących, już zmergowanych migracji; nowa zmiana = nowa migracja,
- w razie konfliktu Gita na pliku migracji – nie scalać ich zawartości, tylko wygenerować nową migrację po zmergowaniu zmian encji,
- kiedy dwie migracje dotykają tej samej tabeli w niekompatybilny sposób, uzgodnić kolejność i ewentualnie poprawić jedną z migracji jeszcze przed merge.
Dobrą praktyką jest nadawanie migracjom opisowych nazw (w nowszych wersjach Doctrine można użyć nazwy klasy zamiast gołego timestampu), np. Version20240105120000_AddUserLastLogin. Ułatwia to debugowanie historii zmian.

Projektowanie bezpiecznych zmian schematu
Sam mechanizm migracji nie rozwiązuje problemu, jak wprowadzać zmiany w schemacie bez przestojów i utraty danych. Klucz leży w tym, jak modelować sekwencję migracji i kodu aplikacji.
Strategia expand–contract (rozszerz–skurcz)
Przy systemach działających 24/7 sensowną strategią jest podejście „expand–contract”: najpierw rozszerzenie schematu w sposób kompatybilny wstecz, potem aktualizacja aplikacji, na końcu usuwanie starych elementów.
Przykładowa zmiana nazwy kolumny full_name na first_name + last_name:
- Expand – migracja 1:
- dodaje kolumny
first_name,last_name(nullable), - kopiuje wartości z
full_namedo nowych kolumn w prosty sposób (np. rozbicie po pierwszej spacji), - utrzymuje starą kolumnę
full_name.
- dodaje kolumny
- Zmiana aplikacji – nowa wersja kodu przestaje używać
full_name, a zapis/odczyt odbywa się przezfirst_name/last_name. - Contract – migracja 2, kilka deployów później:
- usuwa kolumnę
full_name, ewentualne indeksy, triggery, - dostosowuje constrainty, jeśli zależały od starej struktury.
- usuwa kolumnę
DLatego przy dużych zmianach w modelu relacyjnym lepiej mieć dwie lub trzy migracje i kilka wdrożeń pośrednich, niż jedną „magicznie” zmieniającą wszystko na raz.
Migracje z modyfikacją danych (data migrations)
Część zmian schematu wymaga także migracji samych danych: przeliczenia wartości, przeniesienia rekordów, agregacji czy uzupełnienia nowych kolumn. Doctrine migracje dopuszczają wykonywanie dowolnego SQL, można więc łączyć DDL (zmiany struktury) z DML (INSERT/UPDATE/DELETE).
Przykład dodania kolumny age_group na podstawie istniejącego birth_date:
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE users ADD age_group VARCHAR(20) DEFAULT NULL');
// Prosty przykład – w bardziej złożonych przypadkach lepiej użyć procedury lub skryptu jednorazowego.
$this->addSql("
UPDATE users
SET age_group = CASE
WHEN birth_date IS NULL THEN 'unknown'
WHEN TIMESTAMPDIFF(YEAR, birth_date, CURDATE()) < 18 THEN 'minor'
ELSE 'adult'
END
");
}
Jeśli logika jest skomplikowana, lepiej unikać wywoływania całego kodu domenowego z migracji (np. serwisów, event busów). Migracja powinna być powtarzalna, deterministyczna i niezależna od aktualnej implementacji aplikacji. Czasem rozsądniej jest przygotować jednorazowy skrypt administacyjny, a w migracji ograniczyć się do „ram” (kolumny, indeksy).
Zarządzanie czasochłonnymi migracjami
Na produkcji problematyczne są migracje, które:
- blokują tabele na długi czas (
ALTER TABLEna dużej tabeli), - przeliczają miliony rekordów w jednej transakcji,
- wymagają pełnych skanów indeksów w godzinach szczytu.
Jeśli baza wspiera operacje online (np. online index rebuild w PostgreSQL lub MySQL), warto to wykorzystać w SQL generowanym w migracjach. W przeciwnym wypadku można:
- podzielić zmiany na mniejsze porcje – np. migracja tworzy nową tabelę i indeksy, a dane przenoszone są partiami przez zadanie w tle,
- czasochłonne
UPDATElubDELETEwykonać w batchach, np. po 1000–10000 rekordów, sterowanych przez dodatkowe joby, - zablokować okno wdrożeniowe w nocy/poza szczytem i uprzedzić zespół biznesowy, jeśli przerwa jest nieunikniona.
Przy bardzo dużych tabelach przydatne są dodatkowe zabezpieczenia, np. limit czasu wykonania komendy migracji ustawiony przez CI/CD i automatyczne rollbacki w narzędziach infrastrukturalnych.
Zaawansowane aspekty migracji w Doctrine
Wersjonowanie wielu baz i połączeń
Aplikacje coraz częściej korzystają z więcej niż jednej bazy: osobne repozytorium danych operacyjnych, raportowych, czasem dodatkowe bazy pomocnicze. Doctrine radzi sobie z tym scenariuszem dzięki koncepcji wielu połączeń (connections) i menedżerów encji (entity managers).
Konfiguracja migracji dla wielu połączeń polega na zdefiniowaniu osobnych ścieżek i tabel wersji:
doctrine_migrations:
migrations_paths:
'AppMigrationsMain': '%kernel.project_dir%/migrations/main'
'AppMigrationsReporting': '%kernel.project_dir%/migrations/reporting'
storage:
table_storage:
table_name: 'doctrine_migration_versions'
Dla każdej bazy można zdefiniować osobny zestaw komend (np. innym namespace w konsoli lub aliasy), tak aby było jasne, która migracja dotyczy którego schematu. Kluczowe, aby nie mieszać migracji kilku baz w jednej ścieżce – utrudnia to rollback i audyt.
Niestandardowe typy pól i mapping do DBAL
Doctrine umożliwia definiowanie własnych typów DBAL – np. dla wartości obiektowych (Value Objects) czy specyficznych formatów danych (JSON, ENUM). Z perspektywy migracji istotne jest, aby każdy taki typ miał poprawne odwzorowanie na natywny typ bazodanowy oraz prawidłową informację o zmianach.
Przykładowa deklaracja typu:
final class EmailAddressType extends DoctrineDBALTypesType
{
public const NAME = 'email_address';
public function getName(): string
{
return self::NAME;
}
public function getSQLDeclaration(array $fieldDeclaration, DoctrineDBALPlatformsAbstractPlatform $platform): string
{
// Mapowanie na natywny typ – np. VARCHAR(255)
return $platform->getVarcharTypeDeclarationSQL([
'length' => $fieldDeclaration['length'] ?? 255,
]);
}
public function convertToDatabaseValue($value, DoctrineDBALPlatformsAbstractPlatform $platform): ?string
{
if ($value === null) {
return null;
}
if (!$value instanceof EmailAddress) {
throw new InvalidArgumentException('Value must be instance of EmailAddress or null.');
}
return $value->toString();
}
public function convertToPHPValue($value, DoctrineDBALPlatformsAbstractPlatform $platform): ?EmailAddress
{
if ($value === null || $value === '') {
return null;
}
return EmailAddress::fromString($value);
}
public function requiresSQLCommentHint(DoctrineDBALPlatformsAbstractPlatform $platform): bool
{
// Konieczne, aby schema-tool poprawnie wykrywał typ i nie generował zbędnych migracji
return true;
}
}
Kluczowe jest tu poprawne mapowanie na fizyczny typ oraz implementacja requiresSQLCommentHint(). Jeśli typ zwróci true, Doctrine doda komentarz w schemacie (np. COMMENT '(DC2Type:email_address)'), dzięki czemu narzędzia porównujące schemat z metadanymi nie będą traktowały kolumny jako „zwykłego VARCHAR” i nie wygenerują fałszywych zmian przy każdej analizie schematu.
Przy typach takich jak json, enum czy geolokalizacja opłaca się przetestować cykl: wygeneruj schemat, wygeneruj migrację z różnic, uruchom ją, po czym ponownie spróbuj wygenerować migrację. Jeśli za drugim razem plik migracji jest pusty, konfiguracja typu i mapping do DBAL są spójne. Jeśli Doctrine ciągle „widzi” różnice, zwykle brakuje komentarza typu albo deklaracja w metadanych nie odpowiada dokładnie typowi w bazie (np. inna długość, brak nullable, inne default).
Przy customowych typach migrowanie między wersjami bazy potrafi ujawnić dodatkowe pułapki. Przykład: w MySQL zmienia się sposób przechowywania json między wersjami albo silnik nie wspiera jeszcze konkretnego wariantu enum. Wtedy konieczne są migracje, które nie tylko zmieniają kolumnę, ale i dostosowują definicję typu (np. zastępują enum tabelą słownikową). Jeśli typ DBAL jest wykorzystywany szeroko, każda taka decyzja powinna być przemyślana pod kątem wpływu na generowanie kolejnych migracji.
Doświadczone zespoły traktują migracje Doctrine jako wspólny kontrakt pomiędzy modelem obiektowym a rzeczywistą bazą: każda zmiana encji ma swój ślad w historii wersji schematu, a każda decyzja w SQL-u jest świadoma konsekwencji dla kodu. Przy takim podejściu narzędzia Doctrine stają się nie tyle „generatorami ALTER TABLE”, ile kontrolowanym mechanizmem ewolucji danych, który pozwala bezpiecznie rozwijać nawet złożone systemy, bez paniki przy każdym deployu.
Refaktoryzacja encji a stabilność schematu
Przy większych porządkach w modelu obiektowym (wydzielanie modułów, agregatów, zastępowanie dziedziczenia kompozycją) łatwo wygenerować lawinę migracji, które w SQL-u niczego realnie nie zmieniają albo mieszają kilka różnych decyzji w jedną trudną do zrozumienia operację. Schemat bazy powinien ewoluować wolniej niż kod – zmiana nazwy klasy czy pakietu nie musi oznaczać zmiany nazwy tabeli.
Sensowna strategia to rozdzielenie refaktoryzacji na warstwę:
- obiektową – nazwy klas, namespace, pakiety, relacje w kodzie,
- persistencji – nazwy tabel, kolumn, kluczy, constraintów.
Jeśli encja UserAccount zostaje rozbita na User i Account, ale na poziomie bazy nadal wygodnie jest trzymać wszystko w jednej tabeli users, mapowanie można dostosować bez naruszania schematu – np. za pomocą atrybutów lub mapowania XML/YAML, zmieniając tylko logikę w encjach i repozytoriach. Migracja jest potrzebna dopiero wtedy, gdy naprawdę pojawia się nowa tabela, relacja albo constraint.
Przy takich zmianach przydaje się stała zasada: „najpierw wyczyść model i mapowanie bez ruszania schematu, dopiero potem projektuj SQL pod nową architekturę”. Generowanie migracji po każdej małej refaktoryzacji wymusza niepotrzebne ALTER-y i utrudnia merge kilku gałęzi z pracami nad tym samym obszarem domeny.
Strategie nazw migracji i ich czytelność
Pliki migracji to prosta, ale niezwykle cenna dokumentacja decyzji o danych. Chaos w nazwach szybko mści się przy debugowaniu, bo na liście mamy dziesiątki pozycji typu Version20240623103045 bez opisu. Minimalne udogodnienie to połączenie numeru wersji (timestamp) z krótkim opisem celu:
php bin/console doctrine:migrations:generate --editor-cmd=code
--migrations-path=migrations
--formatted
// po wygenerowaniu – zmiana nazwy pliku:
Version20240623103045_add_age_group_to_users.php
Przy ręcznej zmianie nazwy pliku kluczowe jest, aby nie modyfikować nazwy klasy wewnątrz migracji – to ona identyfikuje wersję. Dodatkowo na górze pliku opłaca się zostawić krótki komentarz z kontekstem biznesowym:
/**
* Dodanie age_group do users – przygotowanie pod segmentację w panelu raportowym.
*/
final class Version20240623103045 extends AbstractMigration
{
// ...
}
Komentarz nie zastąpi opisu na PR-ze, ale po roku łatwiej zrozumieć, dlaczego akurat ta kolumna istnieje, i czy można ją bezpiecznie usunąć w ramach porządków.
Migracje w zespołach i konflikt wersji
Kilku deweloperów pracujących równolegle nad modelem danych niemal gwarantuje konflikty migracji. Typowy scenariusz: dwie osoby generują migrację, obie dostają plik z podobnym timestampem, jedna gałąź ląduje w main, druga trafia na konflikt w doctrine_migration_versions lub ma inną strukturę, niż baza oczekuje po rebase.
Najprostszy proces, który ogranicza bałagan:
- Zmiany w schemacie powstają na krótkich gałęziach i są szybko mergowane.
- Przed generowaniem migracji deweloper aktualizuje lokalny
maini stosuje najnowsze migracje. - Jeśli po rebase nowa migracja przestaje pasować (np. encja była już zmieniona w innej gałęzi), generuje się ją jeszcze raz albo ręcznie dostosowuje SQL.
Migracje nie są niezmiennym artefaktem – dopóki nie trafią na środowisko współdzielone (stage, produkcja), można je poprawiać, scalać lub usuwać. Dopiero po wykonaniu na wspólnej bazie plik staje się częścią kontraktu między kodem a środowiskiem i wszelkie korekty wymagają nowej migracji.
Przy większych projektach pomaga też umówienie „właściciela” obszaru danych: osoba decyduje, czy dwie gałęzie, które dotykają tych samych tabel, mają być zmergowane w określonej kolejności, czy jedna z migracji powinna zostać przerobiona. To dużo tańsze niż gaszenie pożaru, gdy na stage nie da się stosować migracji, bo sekwencja ALTER-ów jest sprzeczna.
Testowanie migracji w pipeline CI/CD
Migracje są kodem wykonywanym na najcenniejszym zasobie aplikacji – danych. Uruchamianie ich wyłącznie „na żywo” na produkcji to proszenie się o kłopoty. Standardowy krok w pipeline może wyglądać tak:
- Start świeżej bazy testowej (docker, in-memory, oddzielny schema).
- Wygenerowanie schematu „od zera” na podstawie metadanych (np.
schema:createalbo własna komenda). - Uruchomienie wszystkich migracji w kolejności.
- Ponowne porównanie schematu z metadanymi.
Jeśli krok 3. się nie powiedzie – migracja jest wadliwa technicznie (błąd SQL, brak tabeli, konflikt constraintów). Jeśli krok 4. pokazuje różnice, oznacza to niespójność między schematem tworzonym „na czysto” a tym, do którego prowadzi łańcuch migracji. To szczególnie istotne, kiedy migracje nie są tylko odzwierciedleniem aktualnego modelu, ale zawierają dodatkowe indeksy, widoki czy kolumny pomocnicze.
W pipeline można dorzucić jeszcze testy czasowe – np. proste „smoke testy” na kopii produkcyjnej w osobnym środowisku: odtworzenie dumpa, uruchomienie migracji, pomiar czasu i zgrubna ocena, czy nie przekraczamy okna wdrożeniowego. Nie chodzi tu o dokładną symulację, ale o rychłe wychwycenie migracji, które na dużym zbiorze danych przestają być trywialne.
Migracje a feature toggles i rollout krokowy
Jeden z bezpieczniejszych wzorców wdrażania złożonych zmian funkcjonalnych łączy migracje schematu z feature flagami. Idea jest prosta: schemat jest przygotowywany wcześniej, aplikacja potrafi działać zarówno z nową, jak i starą strukturą, a logika biznesowa jest uruchamiana stopniowo przez włączenie flag w konfiguracji.
W praktyce scenariusz bywa następujący:
- Migracja 1: dodanie nowych kolumn/tabel, ale bez usuwania starej struktury.
- Release A: kod zaczyna zapisywać dane równolegle w stary i nowy sposób, odczyt korzysta jeszcze głównie ze starego.
- Skrypt lub migracja data-migration: przeniesienie i ujednolicenie istniejących danych.
- Release B: odczyt przełączany jest na nową strukturę, stara służy tylko jako fallback.
- Migracja 2: usunięcie nieużywanej już struktury.
Feature flagi umożliwiają cofnięcie się na poziomie logiki (rollback kodu) bez konieczności natychmiastowego cofania zmian w schemacie. To przydatne zwłaszcza w systemach, gdzie DDL rollback jest kosztowny albo ryzykowny (np. ALTER TABLE ... DROP COLUMN na dużych tabelach).
Migracje w podejściu „blue-green” i zero-downtime
Strategie wdrożeń typu blue-green czy rolling update mocno wpływają na to, jak projektuje się migracje. Główna zasada: w każdej chwili część procesów może używać starej wersji kodu, a część – nowej. Schemat bazy musi więc być kompatybilny wstecz i do przodu przynajmniej przez okres przejściowy.
Parę praktycznych reguł przy takich wdrożeniach:
- Dodawanie kolumn jest zazwyczaj bezpieczne – starszy kod ich ignoruje, nowszy może z nich korzystać dopiero po deployu.
- Usuwanie kolumn lub tabel, których stary kod jeszcze dotyka, jest zabronione aż do momentu, gdy żadna instancja starej aplikacji nie jest już aktywna.
- Zmiana typu kolumny „w miejscu” (np.
INT→BIGINT, zmiana skaliDECIMAL) powinna być poprzedzona etapem z kolumną równoległą i migracją danych.
Dla bardziej skomplikowanych scenariuszy wykorzystuje się „rozszerz – migruj – skurcz” (expand-migrate-contract):
- Rozszerz: migracja dodaje nową strukturę, nie naruszając starej.
- Migruj: kod stopniowo przenosi się na nową strukturę, dane są kopiowane lub synchronizowane.
- Skurcz: po upewnieniu się, że kod i dane korzystają tylko z nowej struktury, migracja usuwa stare elementy.
Doctrine nie wymusza takiego wzorca, ale migracje są dobrym miejscem, aby go ustandaryzować: opis w komentarzach, podział na kolejne klasy migracji, świadoma kolejność release’ów.
Specyfika różnych silników bazodanowych
Narzędzia Doctrine starają się ujednolicić obsługę wielu platform, ale szczegóły migracji w PostgreSQL, MySQL czy SQL Serverze potrafią znacząco się różnić. Ta sama deklaracja encji może generować inny SQL, a co za tym idzie – inne zachowanie przy zmianach.
Kilka typowych rozbieżności:
- MySQL inaczej traktuje
DEFAULT CURRENT_TIMESTAMPi aktualizacje timestampów niż PostgreSQL. - W PostgreSQL łatwiej o „online” operacje na indeksach (
CONCURRENTLY), ale mają one swoje ograniczenia (brak transakcji wokół całego ALTER). - W niektórych wersjach MySQL dodanie indeksu na dużej tabeli potrafi ją zablokować na długi czas, jeśli nie ma wsparcia dla algorytmów online.
Przy projektach wieloinstalacyjnych (ten sam kod, różne bazy klientów, niekiedy różne wersje silnika) migracje muszą uwzględniać najniższy wspólny mianownik. Czasem lepszym wyjściem jest explicit SQL w migracji, który świadomie wykorzystuje możliwości konkretnego silnika, zamiast polegać wyłącznie na generowanym DDL. W takim przypadku przydają się warunki oparte na platformie:
public function up(Schema $schema): void
{
$platform = $this->connection->getDatabasePlatform()->getName();
if ($platform === 'postgresql') {
$this->addSql('CREATE INDEX CONCURRENTLY idx_users_email ON users (email)');
} else {
$this->addSql('CREATE INDEX idx_users_email ON users (email)');
}
}
Tego typu różnicowanie należy stosować oszczędnie, ale bywa konieczne, jeśli wymagania niefunkcjonalne (czas blokady, okno wdrożeniowe) są inne na poszczególnych instalacjach.
Migracje a integracja z narzędziami administracyjnymi
W niektórych organizacjach baza danych jest pod kontrolą zespołu DBA, który utrzymuje własne skrypty, polityki indeksowania i backupu. Doctrine nie musi być jedynym źródłem prawdy o schemacie, lecz wtedy trzeba ustalić granice odpowiedzialności.
Spotykane są dwa modele:
- Modele rozdzielone – Doctrine zarządza „warstwą aplikacyjną” (tabele encji, podstawowe indeksy), a DBA utrzymuje osobne obiekty: widoki raportowe, materializowane agregaty, procedury, dodatkowe indeksy na potrzeby analityki.
- Model łączony – cały schemat jest utrzymywany w migracjach Doctrine, a DBA reviewuje PR-y i dorzuca swoje wymagania bezpośrednio w tych samych plikach.
W pierwszym przypadku ważne, aby narzędzia porównujące schemat z metadanymi nie kasowały obiektów zarządzanych przez DBA. Zwykle oznacza to, że generowanie migracji opiera się tylko na encjach, a tabele czy widoki „zewnętrzne” nie są odwzorowane w metadanych Doctrine. W drugim – migracje stają się wspólnym repozytorium wiedzy o schemacie i muszą być utrzymywane z takim samym rygorem jak kod produkcyjny.
W obu modelach granica powinna być dobrze opisana: które obiekty są „własnością” aplikacji, a które – infrastruktury. Unika się wtedy sytuacji, kiedy automatycznie wygenerowana migracja kasuje indeks przygotowany przez DBA, bo Doctrine „nie widzi” go w swoim modelu.
Migracje a refaktoryzacja kluczy głównych i obcych
Zmiana typu klucza głównego (np. z INT na UUID) lub jego semantyki należy do najtrudniejszych operacji w relacyjnej bazie. Doctrine może pomóc wygenerować podstawowy DDL, ale bez ręcznej pracy się nie obejdzie.
Możliwy scenariusz migracji do UUID:
- Dodanie nowej kolumny
uuidw tabeli, z unikalnym indeksem, ale jeszcze nie jako PK. - Wygenerowanie wartości UUID dla istniejących rekordów (data-migration).
- Stopniowa zmiana kodu, tak aby nowe rekordy operowały na UUID, a stary integerowy PK był jeszcze obecny, lecz nie wykorzystywany w zewnętrznych API.
- Aktualizacja relacji: dodanie kolumn z UUID w tabelach zależnych, wypełnienie ich na podstawie starego FK, przestawienie constraintów.
- Ostateczne usunięcie dawnych kolumn PK/FK po upewnieniu się, że żadna część aplikacji już z nich nie korzysta.
Migracje Doctrine w takim scenariuszu stanowią szkielet: deklarują nowe kolumny, indeksy i constrainty, ale większość pracy dotyczy kolejności działań i synchronizacji z kodem. Dobrze jest dzielić ten proces na bardzo małe kroki – każda migracja powinna być możliwa do wykonania i ewentualnego cofnięcia bez szkody dla spójności danych.
Przy tak głębokich zmianach kluczowe jest, aby każdy krok był idempotentny i jednoznacznie weryfikowalny. Migracje strukturalne (DDL) powinny być rozdzielone od migracji danych (DML), a te drugie – zaprojektowane tak, by dało się je uruchomić kilka razy bez skutków ubocznych. Pomagają w tym dodatkowe ograniczenia (np. unikalny indeks na nowym UUID) oraz mechanizmy walidacji – proste zapytania porównujące liczby rekordów, spójność relacji czy obecność duplikatów po migracji.
Zależnie od rozmiaru bazy strategia może wyglądać inaczej. Przy niewielkiej liczbie rekordów bardziej opłaca się jednorazowa, krótka przerwa i pełna migracja w jednym oknie serwisowym. Przy dużych wolumenach trzeba raczej sięgnąć po migrację strumieniową: przetwarzać dane partiami, stosować „dual write” (czasowy zapis do starej i nowej kolumny/relacji) i dopiero na końcu przełączyć odczyt. Doctrine nie zapewni gotowych narzędzi do takiego dual write, ale struktury pod niego (dodatkowe kolumny, indeksy, constrainty) są właśnie domeną migracji.
Sama refaktoryzacja kluczy szybko ujawnia też miejsca, w których model obiektowy oderwał się od faktycznego schematu. Zanim powstanie pierwsza migracja, dobrze przeprowadzić przegląd encji i ich powiązań z realnymi relacjami w bazie: czy wszystkie relacje są odwzorowane, czy nie ma „ukrytych” kluczy trzymanych jako zwykłe pola tekstowe, czy logika domenowa nie polega na samym formacie identyfikatora (np. zakodowana data w integerze). Im wcześniej zostaną wykryte takie rozjazdy, tym mniej niespodzianek w trakcie wdrożenia.
Na koniec cały proces układa się w jedną powtarzalną sekwencję: projekt encji, świadome generowanie lub ręczne pisanie migracji, kontrolowane wdrożenie z uwzględnieniem ograniczeń silnika i środowiska, a potem obserwacja zachowania systemu. Doctrine dostarcza solidnego minimum – mapowania, narzędzia CLI, integrację z różnymi platformami – natomiast jakość i bezpieczeństwo zmian w schemacie zależą od dyscypliny procesu, komunikacji w zespole i umiejętnego łączenia automatyki z ręczną kontrolą.
Najczęściej zadawane pytania (FAQ)
Po co używać migracji Doctrine zamiast ręcznych skryptów SQL?
Migracje zamieniają chaotyczne, ręczne zmiany w bazie w powtarzalny proces: każda modyfikacja schematu jest opisana w wersjonowanej klasie PHP i może być uruchomiona jednakowo na dev, staging i produkcji. Znika problem „dryfującej” bazy, gdzie na każdym środowisku są nieco inne tabele i kolumny.
Dodatkowo migracje są częścią historii repozytorium – da się sprawdzić, kto, kiedy i dlaczego zmienił schemat. Ułatwia to odtwarzanie bazy z backupu, cofanie się do poprzednich wersji aplikacji i zmniejsza ryzyko niespodzianek przy wdrożeniach.
Jak wygląda typowy proces pracy ze schematem bazy w Doctrine?
Najczęściej przepływ jest jeden i jasno zdefiniowany: najpierw zmieniasz encję (dodajesz pole, relację, zmieniasz typ), następnie generujesz lub piszesz migrację, uruchamiasz ją lokalnie razem z testami, a potem wysyłasz PR do review – łącznie z kodem migracji.
Po akceptacji migracje są odpalane automatycznie na środowisku testowym (np. przez CI), a na końcu według procedury na produkcji. Dzięki temu schemat bazy jest zawsze zsynchronizowany z modelem obiektowym, a baza przestaje być „czarną skrzynką”, do której ktoś coś „dopisał na szybko”.
Czym różni się podejście code-first od schema-first w Doctrine?
W podejściu code-first źródłem prawdy są encje i ich mapping – najpierw projektujesz klasy PHP, a Doctrine na tej podstawie generuje schemat bazy i migracje. To naturalny styl pracy dla Doctrine: zmiana w encji prowadzi do zmiany w metadanych, a z nich powstaje migracja.
Schema-first oznacza odwrotny kierunek: najpierw projektujesz bazę (np. diagramy, ręczne SQL), a dopiero potem dopasowujesz do niej encje. W Doctrine jest to możliwe, ale częściej wymusza ręczne tworzenie migracji i ciągłe pilnowanie, żeby mapping encji nie rozjechał się z ręcznie projektowanym schematem.
Czym jest encja w Doctrine i jak łączy się z tabelą w bazie?
Encja to zwykła klasa PHP reprezentująca obiekt domenowy, np. User czy Order. Dzięki atrybutom, adnotacjom lub plikom XML/YAML klasa jest powiązana z konkretną tabelą i kolumnami w bazie: określasz typy, długości, relacje, indeksy i ograniczenia.
Na przykład pole #[ORMColumn(type: "string", length: 180, unique: true)] dla $email mówi Doctrine, że w tabeli musi istnieć kolumna string o długości 180 znaków z unikalnością. Jeśli zmienisz definicję pola w encji, zmiana powinna zostać odzwierciedlona w schemacie przez kolejną migrację.
Jak Doctrine ustala różnice między encjami a aktualnym schematem bazy?
Doctrine najpierw odczytuje aktualny schemat bezpośrednio z bazy (migawka: istniejące tabele, kolumny, indeksy) za pomocą warstwy DBAL. Następnie buduje docelowy model schematu z metadanych encji: mappingu pól, relacji i indeksów.
Porównując te dwa opisy, oblicza tzw. diff – zestaw operacji SQL potrzebnych, aby doprowadzić bazę do stanu zgodnego z encjami. Z tego diffu generowana jest treść migracji, którą potem możesz dostosować ręcznie lub uruchomić tak, jak została wygenerowana.
Co to jest migracja Doctrine i jak wygląda przykładowa zmiana schematu?
Migracja Doctrine to klasa PHP dziedzicząca po AbstractMigration z metodą up() (i opcjonalnie down()), która opisuje, jak przekształcić schemat z jednej wersji do kolejnej. W środku znajdują się wywołania $this->addSql(...) lub operacje na obiekcie Schema.
Prosty przykład: dodanie kolumny last_login do tabeli users sprowadza się do migracji z metodą up() zawierającą $this->addSql('ALTER TABLE users ADD last_login DATETIME DEFAULT NULL'). Po jej uruchomieniu wszystkie środowiska zyskują tę samą zmianę schematu w kontrolowany sposób.
Czy w projekcie można mieszać ręczne SQL z migracjami Doctrine?
Można, ale tylko jeśli zachowasz jeden, spójny kierunek zmian: encje → migracje → baza. Ręczne SQL-e powinny wówczas trafiać do migracji (np. jako customowe indeksy, constrainty specyficzne dla danego RDBMS), zamiast być odpalane „z boku” na produkcji.
Jeśli część zmian wykonujesz ręcznie poza systemem migracji, a część przez Doctrine, schemat szybko zaczyna dryfować. Efekt to konflikty przy odtwarzaniu bazy, problemy z rollbackiem i brak zaufania do tego, co faktycznie jest odpalone na produkcji.
Kluczowe Wnioski
- Ręczne zarządzanie SQL-em bez migracji prowadzi do „dryfu schematu” – różne środowiska mają inne tabele, brakujące kolumny i nieudokumentowane zmiany, co generuje realne koszty i ryzyko błędów na produkcji.
- Migracje Doctrine zamieniają chaotyczne skrypty SQL w powtarzalny, wersjonowany proces: każdą zmianę schematu można odtworzyć na dowolnym środowisku, w kontrolowanym momencie i z pełną historią w repozytorium.
- W dojrzałym procesie developmentu migracje są tak samo istotne jak testy czy code review – zmiana encji automatycznie pociąga za sobą przygotowanie migracji, jej uruchomienie lokalnie, sprawdzenie w CI i wdrożenie na produkcję według jasnej procedury.
- Doctrine spina model obiektowy z bazą przez metadata: zna docelowy model (encje) i aktualny schemat (z bazy przez DBAL), dzięki czemu potrafi wygenerować różnicę i zamienić ją w konkretną migrację SQL.
- Podejście „code-first” jest naturalnym wyborem przy Doctrine – źródłem prawdy jest kod encji, a baza jest jego projekcją; „schema-first” jest możliwe, ale wymaga więcej ręcznej pracy i pilnowania zgodności mappingu z projektowaną ręcznie bazą.
- W praktyce często stosuje się hybrydę: encje definiują główny model, a krytyczne elementy bazy (indeksy, specyficzne typy, złożone constrainty) są dopracowywane w osobnych, świadomie pisanych migracjach.






