Database Schemas That Survive the Client Changing Their Mind
A practical field guide to designing MySQL schemas for clients who say "just one more field" every sprint. Status columns, soft deletes, JSON escape hatches, and when to say no.
Database Schemas That Survive Client Changes
Every client says "just one more field". After eight client projects, I have a schema playbook that absorbs those changes without migrations, downtime, or tears. It is not clever. It is just disciplined.
The five rules
1. Status columns are enums, not booleans
If you have is_active, is_pending, is_archived on the same row, you have a bug waiting to happen. Replace with a single status enum:
status ENUM('draft', 'pending', 'active', 'archived', 'deleted')
Yes, deleted is in there. Soft deletes are non-negotiable for client work. The day someone "accidentally" deletes a paid order, you will thank me.
2. JSON columns for "just one more field"
MySQL 8 JSON columns are your escape hatch. Every entity gets a meta JSON column. When the client asks for a new field that does not need indexing — a favorite color, an internal note, a secondary phone number — it goes into meta without a migration.
ALTER TABLE orders ADD COLUMN meta JSON NOT NULL DEFAULT (JSON_OBJECT());
The rule: anything in meta is allowed to disappear. If it becomes load-bearing, promote it to a real column with a migration.
3. Timestamps: four of them
Every table gets:
created_atupdated_atdeleted_at(nullable, for soft deletes)published_at(nullable, for content that goes live later)
Laravel's migrations give you the first two by default. Add the other two. You will need them.
4. UUIDs for public IDs, ints for primary keys
Internal primary key is a BIGINT auto-increment. Public-facing identifier (in URLs, API responses) is a UUID stored in a separate uuid CHAR(36) column with a unique index.
The int PK keeps joins fast and storage small. The UUID keeps URLs unguessable and decoupled from row count — clients cannot tell how many orders you have processed by looking at /orders/1042.
5. Money is always integer cents
DECIMAL(10,2) works until you start doing currency conversions. Store money as BIGINT cents. $19.99 is 1999. Format on the way out. This has saved me from at least three rounding bugs that would have been embarrassing in production.
The "say no" list
These requests always come. Always say no.
- "Can we just store the user's password in plain text so support can log in as them?" No. Impersonation feature, or nothing.
- "Can we use the email column as the primary key?" No. Emails change. IDs do not.
- "Can we have one big
userstable with 80 columns?" No. Split intousers(auth) anduser_profiles(everything else). Your sanity depends on it. - "Can we skip the foreign keys for performance?" No. The performance difference is noise. The data integrity difference is catastrophic.
The migration discipline
- Migrations are reviewed in PRs, not applied directly.
- Every migration is reversible. If you cannot write the
down()method, you have not designed theup()method correctly. - Production migrations run during low-traffic hours, even when "they should be safe".
- After every migration, a backup is taken. Always.
The takeaway
The schema you ship on day one is not the schema you will have on day 365. Design for change. The five rules above buy you 90% of the flexibility you need, and the discipline of saying "no" to the rest keeps the schema legible three years in.