Doctrine Models with Ptah: Reviewing SQL Before Deployment
Keep Doctrine entities and queries, use Ptah to review and rehearse SQL migrations, and compare the result with Doctrine's native migration workflow.
Ptah helps you plan, review, and apply database migrations. Try in your browser
Adding an index through a Doctrine migration gives us working SQL, but we also want to check how that index will be built before deployment. We use Ptah to enforce a concurrent-index policy and rehearse the migration on a shadow database. Doctrine still maps the entities and runs application queries.
The case is an invoice service. We add an optional memo and an index on
(customer_id, status) for the customer’s open-invoice list. We ran the
change through both Doctrine Migrations and Ptah, starting with the same
table and two existing invoices.
The tested versions were Doctrine ORM 3.7.1, Doctrine Migrations 3.9.7, DBAL 4.4.4, PHP 8.5.10, Ptah 0.11.3, and PostgreSQL 18.6. The supporting files contain the entities, native PHP migrations, Ptah SQL, dependency lock, and recorded results.
Start with the native migration
Section titled “Start with the native migration”We added a nullable memo property to the Invoice entity and a class-level
index mapping. On the development database, Doctrine generated and applied
the change:
php vendor/bin/doctrine-migrations diff --no-interactionphp vendor/bin/doctrine-migrations migrate --no-interactionphp console.php orm:validate-schemaconsole.php uses Doctrine’s standard ConsoleRunner. The generated PHP
migration submits this SQL through addSql:
ALTER TABLE app.invoices ADD memo TEXT DEFAULT NULL;CREATE INDEX invoices_customer_status_idx ON app.invoices (customer_id, status);The migration succeeded, and Doctrine reported that the database matched
the mappings. This is a working native deployment path. The ordinary
CREATE INDEX is the statement we want to review before using it on a busy
invoice table.
Give Ptah the schema Doctrine actually built
Section titled “Give Ptah the schema Doctrine actually built”Inspect the updated development database through Ptah:
ptah schema inspect --db-url "$DEV_URL" \ --schemas app --format hcl > schema.hclThis gives Ptah a desired schema without a PHP schema exporter. Inspect a development database you have just built and validated against the intended models; an arbitrary database could contain changes the models never requested.
The deployment database still has the old invoice table. Its initial Ptah
migration is already recorded. DB_URL names that target; SHADOW_URL names
a separate disposable database. Both Ptah URLs select search_path=app.
Migration tracking stays in public, outside the inspected application schema.
Our ORM console excludes the runners’ metadata tables from schema validation:
doctrine_migration_versions, schema_migrations, and schema_migrations_log.
Keep that filter in the ORM console; the migration runner needs to see its
own history table.
Our first Ptah generation also used ordinary index creation. Lint reported
PG101 for that build and PG106 for the rollback’s ordinary index drop.
With --fail-on any, the command returned 1. The default error threshold
had returned zero while printing those warnings, so the threshold matters.
Put the index policy in the project
Section titled “Put the index policy in the project”We enabled concurrent index creation and removal in Ptah’s native configuration:
diff: concurrent_index: true concurrent_index_drop: trueThen we generated again against a newly initialized copy of the old database:
ptah migrations generate --config ptah.yaml \ --db-url "$DB_URL" --schema-file schema.hcl --schemas app \ --migrations-dir migrations --name invoice_memo \ --shadow-db "$SHADOW_URL"ptah migrations hash --dir migrationsptah migrations lint --dir migrations --latest 2 --fail-on anyPtah produced a transactional migration for the nullable column and a separate nontransactional migration for the index. The relevant index file lines are:
-- +ptah no_transactionCREATE INDEX CONCURRENTLY IF NOT EXISTS "invoices_customer_status_idx" ON "app"."invoices" ("customer_id", "status");Generation rehearsed the new migrations up, down, and up in shadow before writing the files. The final lint returned zero:
No lint findings.Doctrine can execute custom SQL and disable transaction wrapping for a migration. Its overall transaction settings must also permit that operation. Ptah’s advantage here is the configured generation policy, the lint finding, and the shadow rehearsal in the same CLI workflow. Concurrent indexes are a PostgreSQL capability available to either migration runner.
Deploy and exercise the same PHP code
Section titled “Deploy and exercise the same PHP code”ptah migrations up --db-url "$DB_URL" \ --migrations-dir migrations --migrations-schema public --verify-sumptah schema compare --db-url "$DB_URL" \ --schema-file schema.hcl --schemas app --exit-codeThe target applied two pending migrations and matched schema.hcl. A fresh
database replayed all three versions and reached the same schema.
The comparison reported:
No schema differences detected.We ran the same Doctrine application against both deployment paths. It updated the original invoice’s memo, inserted another invoice, and queried the open invoices for customer 42. Both returned:
1|12000|Customer requested a consolidated receipt3|4500|Follow-up orderThe second original invoice remained paid and did not appear in that query. Doctrine’s schema validation also passed against the Ptah-managed database.
Be precise about drift detection
Section titled “Be precise about drift detection”We manually dropped the new index in both databases. Their migration status
commands still reported no pending migrations. Ptah’s schema comparison
returned 1 and named the missing index. Doctrine’s orm:validate-schema
returned 2, and its schema update preview proposed recreating the index.
Both tools caught the changed state when asked the appropriate question. Ptah’s comparison runs from the checked-in schema artifact; Doctrine’s validation runs from the PHP mappings. Neither migration history check alone established that the index still existed. Restoring the index made both schema checks pass again.
Choose the deployment owner deliberately
Section titled “Choose the deployment owner deliberately”Ptah is useful when you want SQL review, migration lint, shadow rehearsal, and schema comparison to use the same commands across PHP and other services. The Prisma and SQLAlchemy examples use that same deployment surface with different application models.
If Doctrine’s native migrations already fit your team’s review and deployment process, that remains a valid choice. In this run, Ptah added a concrete pre-deployment check and an explicit index policy while the Doctrine entities and queries continued to work unchanged.
Put Ptah to work
Example files
- application.php
- bootstrap.php
- cli-config.php
- composer.json
- composer.lock
- console.php
- Dockerfile.txt
- expected/application.txt
- expected/compare.txt
- expected/concurrent-index.sql
- expected/native-change.sql
- generated/1790846069_invoice_memo.down.sql
- generated/1790846069_invoice_memo.up.sql
- generated/1790846119_invoice_memo.down.sql
- generated/1790846119_invoice_memo.up.sql
- generated/ptah.sum
- Invoice-v1.php
- Invoice-v2.php
- measured/commands.json
- measured/composer-version.stderr.txt
- measured/composer-version.txt
- measured/copy-baseline.txt
- measured/copy-schema.txt
- measured/create-doctrine_fresh.txt
- measured/create-doctrine_native.txt
- measured/create-doctrine_plain.txt
- measured/create-doctrine_readme_fresh.txt
- measured/create-doctrine_readme_native.txt
- measured/create-doctrine_readme_ptah.txt
- measured/create-doctrine_reviewed.txt
- measured/create-doctrine_shadow.txt
- measured/create-doctrine_sql.txt
- measured/doctrine_fresh-application.txt
- measured/doctrine_fresh-compare.txt
- measured/doctrine_fresh-rows.txt
- measured/doctrine_fresh-up-v2.stderr.txt
- measured/doctrine_fresh-up-v2.txt
- measured/doctrine_fresh-validate.stderr.txt
- measured/doctrine_fresh-validate.txt
- measured/doctrine_native-application.txt
- measured/doctrine_native-compare-drift.txt
- measured/doctrine_native-compare-restored.txt
- measured/doctrine_native-diff-drift.txt
- measured/doctrine_native-drop-index.txt
- measured/doctrine_native-restore-index.txt
- measured/doctrine_native-rows.txt
- measured/doctrine_native-validate-drift.stderr.txt
- measured/doctrine_native-validate-drift.txt
- measured/doctrine_native-validate-restored.stderr.txt
- measured/doctrine_native-validate-restored.txt
- measured/doctrine_plain-seed.txt
- measured/doctrine_plain-up-v1.stderr.txt
- measured/doctrine_plain-up-v1.txt
- measured/doctrine_readme_fresh-application.txt
- measured/doctrine_readme_fresh-compare.txt
- measured/doctrine_readme_fresh-up.stderr.txt
- measured/doctrine_readme_fresh-up.txt
- measured/doctrine_readme_native-application.txt
- measured/doctrine_readme_ptah-application.txt
- measured/doctrine_readme_ptah-compare.txt
- measured/doctrine_readme_ptah-up.stderr.txt
- measured/doctrine_readme_ptah-up.txt
- measured/doctrine_reviewed-application.txt
- measured/doctrine_reviewed-compare-drift.txt
- measured/doctrine_reviewed-compare-restored.txt
- measured/doctrine_reviewed-compare.txt
- measured/doctrine_reviewed-diff-drift.txt
- measured/doctrine_reviewed-drop-index.txt
- measured/doctrine_reviewed-restore-index.txt
- measured/doctrine_reviewed-rows.txt
- measured/doctrine_reviewed-seed.txt
- measured/doctrine_reviewed-up-v1.stderr.txt
- measured/doctrine_reviewed-up-v1.txt
- measured/doctrine_reviewed-up-v2.stderr.txt
- measured/doctrine_reviewed-up-v2.txt
- measured/doctrine_reviewed-validate-drift.stderr.txt
- measured/doctrine_reviewed-validate-drift.txt
- measured/doctrine_reviewed-validate-restored.stderr.txt
- measured/doctrine_reviewed-validate-restored.txt
- measured/doctrine_reviewed-validate.stderr.txt
- measured/doctrine_reviewed-validate.txt
- measured/doctrine_sql-seed.txt
- measured/doctrine_sql-up-v1.stderr.txt
- measured/doctrine_sql-up-v1.txt
- measured/enable-index-policy.txt
- measured/export-v1.txt
- measured/export-v2.txt
- measured/fresh-seed.txt
- measured/generate-plain-v2.stderr.txt
- measured/generate-plain-v2.txt
- measured/generate-v1.stderr.txt
- measured/generate-v1.txt
- measured/generate-v2-sql.stderr.txt
- measured/generate-v2-sql.txt
- measured/hash-plain-v1.txt
- measured/hash-reviewed-v1.txt
- measured/inspect-desired-hcl.stderr.txt
- measured/inspect-desired-hcl.txt
- measured/lint-default.stderr.txt
- measured/lint-default.txt
- measured/lint-strict.stderr.txt
- measured/lint-strict.txt
- measured/native-diff-v1.txt
- measured/native-diff-v2.txt
- measured/native-seed.txt
- measured/native-status-drift.txt
- measured/native-up-v1.txt
- measured/native-up-v2.txt
- measured/native-validate.stderr.txt
- measured/native-validate.txt
- measured/packages.txt
- measured/php-version.txt
- measured/plain-hash-v2.txt
- measured/postgres-version.txt
- measured/prepare-directories.txt
- measured/prepare-plain.txt
- measured/prepare-sql.txt
- measured/ptah-version.txt
- measured/readme-fresh-seed.txt
- measured/readme-lint.stderr.txt
- measured/readme-lint.txt
- measured/readme-native-seed.txt
- measured/readme-native-v1.txt
- measured/readme-native-v2.txt
- measured/readme-native-validate.stderr.txt
- measured/readme-native-validate.txt
- measured/readme-ptah-seed.txt
- measured/readme-ptah-v1.stderr.txt
- measured/readme-ptah-v1.txt
- measured/replay-migration-files.txt
- measured/replay-native-files.txt
- measured/reviewed-generate-v2.stderr.txt
- measured/reviewed-generate-v2.txt
- measured/reviewed-hash-v2.txt
- measured/reviewed-lint-v2.stderr.txt
- measured/reviewed-lint-v2.txt
- measured/reviewed-status-drift.txt
- measured/reviewed-validate-with-log-filter.stderr.txt
- measured/reviewed-validate-with-log-filter.txt
- measured/runtime-images.json
- measured/save-export-v1.txt
- measured/save-export-v2.txt
- measured/schema-doctrine_fresh.txt
- measured/schema-doctrine_native.txt
- measured/schema-doctrine_plain.txt
- measured/schema-doctrine_readme_fresh.txt
- measured/schema-doctrine_readme_native.txt
- measured/schema-doctrine_readme_ptah.txt
- measured/schema-doctrine_reviewed.txt
- measured/schema-doctrine_shadow.txt
- measured/schema-doctrine_sql.txt
- measured/select-model-v1.txt
- measured/select-model-v2.txt
- measured/sql-compare.txt
- measured/sql-hash-v1.txt
- measured/sql-hash-v2.txt
- measured/sql-up-v2.stderr.txt
- measured/sql-up-v2.txt
- measured/sql-validate.stderr.txt
- measured/sql-validate.txt
- migrations-doctrine/Version20261001091422.php
- migrations-doctrine/Version20261001091443.php
- migrations/1790846069_invoice_memo.down.sql
- migrations/1790846069_invoice_memo.up.sql
- migrations/1790846126_invoice_memo_transactional.down.sql
- migrations/1790846126_invoice_memo_transactional.up.sql
- migrations/1790846127_invoice_memo_concurrent_indexes.down.sql
- migrations/1790846127_invoice_memo_concurrent_indexes.up.sql
- migrations/ptah.sum
- ptah.yaml
- README.md
- schema-v1.sql
- schema.hcl
- schema.sql
- seed.php
- sql-check/1790846069_invoice_memo.down.sql
- sql-check/1790846069_invoice_memo.up.sql
- sql-check/1790846209_invoice_memo_transactional.down.sql
- sql-check/1790846209_invoice_memo_transactional.up.sql
- sql-check/1790846210_invoice_memo_concurrent_indexes.down.sql
- sql-check/1790846210_invoice_memo_concurrent_indexes.up.sql
- sql-check/ptah.sum
- src/Invoice.php
- verified.json