Web Development

Database Schema Migrations for Small PHP Applications - Change Structure Without Guesswork

Database Schema Migrations for Small PHP Applications - Change Structure Without Guesswork

An application release can be rolled back by checking out an earlier commit. A database is less cooperative: it contains state accumulated while the application was running. If a deployment adds a column, transforms thousands of values, and then removes the old column, returning to the earlier code does not reconstruct what was discarded. The practical question is therefore not merely, "How do I run ALTER TABLE?" It is, "How do I make this change repeatable, visible, and recoverable?"

Database schema migrations provide an answer, but not magic. They turn structural changes into ordered artifacts that can be reviewed and tested. They do not make every SQL statement transactional, eliminate deployment locks, or guarantee that lost data can be restored. For a small PHP application, understanding that boundary is more useful than adopting a large tool without understanding its job.

A migration is a recorded transition, not a snapshot

A schema migration describes how to move a database from one known state to the next. It might create a table, add an index, introduce a nullable column, or backfill existing rows. The Doctrine Migrations documentation describes this as versioning the database schema so changes can be reviewed and tested before production deployment.

This differs from keeping only a fresh-install schema dump. A dump can describe what the database should look like today, but it does not explain how an existing installation should travel from last month's structure to today's. A migration preserves that path.

It also differs from an ordinary application transaction. A transaction groups runtime writes such as creating an order and its line items. A schema migration changes the structures those writes depend on. The two ideas overlap when a migration transforms data, but they solve different coordination problems.

The minimum reliable system has two parts

The first part is a directory of migration files stored with the application code. Each file has a unique version and a clear description, for example:

migrations/
  202608310001_add_summary_to_posts.sql
  202608310002_backfill_post_summaries.sql
  202608310003_require_post_summary.sql

The exact naming format is less important than stable identity and deterministic ordering. The scripts belong in version control because a release should carry both the code and the database changes it expects. Redgate's Flyway documentation describes migrations as incremental schema or data changes that run in a consistent order across environments.

The second part is a migration ledger inside the database. A minimal ledger might record a version, description, checksum, execution time, and success state. Before applying anything, the runner compares available files with recorded versions. It applies only pending migrations in order and records the result.

This ledger answers a question that a folder alone cannot: what has this particular database already received? Development, staging, and production can contain the same application repository while being at different migration versions. The history table makes that difference observable.

Do not rewrite deployed history casually

Once a migration has run in a shared or production environment, silently editing it creates two meanings for one version. A fresh database receives the revised SQL, while an existing database retains the effect of the original SQL. Checksums can detect the mismatch, but they cannot decide which state was intended.

The safer default is to append a corrective migration. This can feel untidy, yet the sequence tells the truth about what happened. Historical migrations can be consolidated deliberately later, but that is a separate maintenance operation with a defined baseline, not an invisible edit.

Make compatibility a deployment property

Consider a PHP application that wants to add a required summary field to existing posts. A single migration that adds a NOT NULL column, rewrites every row, and immediately deploys code that requires it concentrates several risks in one moment.

A staged change is easier to reason about:

  1. Add summary as nullable, leaving the old application valid.
  2. Deploy code that writes summaries while still tolerating old rows.
  3. Backfill existing rows in controlled batches and verify the result.
  4. Change reads to rely on the new field only after validation.
  5. Add the required constraint in a later migration.

The SQL below is deliberately illustrative rather than a production-ready recipe:

-- Expand: old code can ignore this nullable column.
ALTER TABLE posts ADD COLUMN summary VARCHAR(500) NULL;

-- A separate, reviewed data migration fills existing rows.
UPDATE posts
SET summary = LEFT(content, 500)
WHERE summary IS NULL;

-- Contract only after the application and data are ready.
ALTER TABLE posts MODIFY summary VARCHAR(500) NOT NULL;

Real content cannot necessarily be summarized by truncating HTML, so the example's UPDATE is not a recommendation for a publishing system. It exposes the question a migration review must answer: does the transformation preserve the meaning of the old data? Sometimes no automatic mapping is honest enough, and the design must allow missing values or manual review.

Expand and contract separates irreversible decisions

The broader version of that staged approach is often called expand and contract. During expansion, the database supports both the old and new structures. Application code may write to both, existing data is backfilled, and the new read path is tested. During contraction, the old path is removed only after it is no longer needed.

The Prisma Data Guide presents this as a sequence: build the new schema beside the old one, adapt clients, migrate data, test, switch reads, stop old writes, and finally remove the original structure. Pramod Sadalage and Martin Fowler describe a related transition phase in evolutionary database design, where old and new access patterns coexist temporarily.

This pattern is useful when multiple application processes may run different releases during deployment, or when a data backfill cannot safely finish inside one maintenance window. Its cost is temporary complexity: duplicate columns, dual writes, compatibility branches, and cleanup work. A small site with an acceptable maintenance window may rationally choose a shorter offline migration instead. "Zero downtime" should not become a slogan that hides a more fragile procedure.

Rollback is not a universal undo button

Migration tools commonly offer a way to define a reverse operation, but inverse SQL is not the same as recovery. Dropping a newly added empty column may be straightforward. Restoring a dropped column and all of its former values is not. Splitting one ambiguous field into two may lose information that cannot be recombined reliably. New writes made after a cutover may have no representation in the old schema.

Database engines also impose their own transaction rules. MariaDB documents that many DDL statements, including ALTER TABLE, cause an implicit commit. Wrapping such statements in START TRANSACTION does not create a universal rollback boundary. Other engines and operations behave differently, so the relevant version's documentation must be checked rather than inferred from application transactions.

A realistic recovery plan may combine several options:

  • Roll the application forward with a corrective migration.
  • Keep old structures temporarily so code can switch back.
  • Restore a tested backup when data has been destroyed or corrupted.
  • Pause deployment and investigate instead of automatically continuing.

A backup is only potential recovery until its restore path has been tested. The acceptable recovery point and recovery time depend on the application; a personal site and a payment system should not pretend to have the same risk model.

A careful workflow for a small team

1. Define the starting state

Record the schema version the migration expects. If production contains manual changes that are absent from version control, resolve that drift before adding more automation. A runner cannot reason safely from a fictional baseline.

2. Keep each change narrow

Separate additive schema work, data backfills, code cutovers, and destructive cleanup when their failure modes differ. Smaller steps are easier to review and diagnose, although making them tiny without a coherent deployment plan can simply move complexity elsewhere.

3. Test the path, not only the final schema

Create a database at the previous release, include representative edge cases, apply the pending migrations, and then run application tests. A clean install proves that today's schema can be created; it does not prove that old data can reach it safely.

4. Review data transformations explicitly

Ask what happens to nulls, duplicate values, invalid historical records, foreign keys, and partially migrated rows. Make a backfill restartable where practical. A script that works only when run exactly once without interruption is difficult to recover after a timeout.

5. Plan backup, lock, and observation windows

Check how the exact database version performs the proposed DDL. Some operations may lock a busy table or rebuild it; the impact depends on the engine, operation, table, and workload. Avoid invented duration estimates. Decide what signals will show progress or failure, and verify application behavior after the migration rather than treating a zero exit code as the whole test.

6. Give one actor authority to migrate

Two application instances racing to apply the same pending migration can produce confusing failures. A deployment job, explicit maintenance command, or runner with appropriate locking should own the operation. Automatic startup migration can be convenient, but it also couples every process start to privileged schema work. That tradeoff deserves an intentional decision.

What migrations cannot decide for you

A migration runner can order scripts, record history, validate checksums, and stop on errors. It cannot determine whether truncating text preserves meaning, whether an index build is acceptable during peak traffic, or whether keeping two representations creates privacy problems. Those are application and operational judgments.

Nor does a framework remove database-specific knowledge. Doctrine, Flyway, or a small internal runner can coordinate work, but MariaDB still defines what its DDL commits and locks. The tool is a checklist with memory, not a substitute for understanding the database.

Conclusion

For a small PHP application, a sound migration practice can begin modestly: ordered files in version control, a trustworthy ledger, one migration authority, tests from the previous state, and a recovery plan proportionate to the change. Additive changes can often be deployed before code depends on them; destructive changes should wait until old readers, writers, and data have been accounted for.

The central shift is from treating schema changes as commands typed during deployment to treating them as part of the software's history. That history does not make failure impossible. It makes the intended path inspectable, repeatable, and easier to question before production data is asked to take it.

References