Skip to content

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.

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:

Terminal window
php vendor/bin/doctrine-migrations diff --no-interaction
php vendor/bin/doctrine-migrations migrate --no-interaction
php console.php orm:validate-schema

console.php uses Doctrine’s standard ConsoleRunner. The generated PHP migration submits this SQL through addSql:

examples/expected/native-change.sql
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:

Terminal window
ptah schema inspect --db-url "$DEV_URL" \
--schemas app --format hcl > schema.hcl

This 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.

We enabled concurrent index creation and removal in Ptah’s native configuration:

examples/ptah.yaml
diff:
concurrent_index: true
concurrent_index_drop: true

Then we generated again against a newly initialized copy of the old database:

Terminal window
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 migrations
ptah migrations lint --dir migrations --latest 2 --fail-on any

Ptah produced a transactional migration for the nullable column and a separate nontransactional migration for the index. The relevant index file lines are:

examples/expected/concurrent-index.sql
-- +ptah no_transaction
CREATE 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:

examples/measured/reviewed-lint-v2.txt
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.

Terminal window
ptah migrations up --db-url "$DB_URL" \
--migrations-dir migrations --migrations-schema public --verify-sum
ptah schema compare --db-url "$DB_URL" \
--schema-file schema.hcl --schemas app --exit-code

The target applied two pending migrations and matched schema.hcl. A fresh database replayed all three versions and reached the same schema. The comparison reported:

examples/expected/compare.txt
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:

examples/expected/application.txt
1|12000|Customer requested a consolidated receipt
3|4500|Follow-up order

The second original invoice remained paid and did not appear in that query. Doctrine’s schema validation also passed against the Ptah-managed database.

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.

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