Schema management
Extending the database safely
The same seam that keeps your code update-safe must also exist in your database. The vendor owns their tables and ships new migrations with every release. The golden rule: never edit a vendor migration. Make every database change in your own additive migration instead — one the vendor’s update will never touch.
This chapter explains how Laravel migrations actually run, why that makes vendor updates safe, and the two ways you add data: a _zaj migration that adds marked columns to a vendor table, and a zajm_ table that your module owns.
How Laravel decides what to run
Section titled “How Laravel decides what to run”Migrations feel like magic until you see the one comparison underneath them. When you run a migration, Laravel compares two lists:
graph LR A["Migration FILES on disk<br/>(what could run)"] B["migrations table in the DB<br/>(what has already run)"] A --> Compare{"In A but<br/>NOT in B?"} B --> Compare Compare -->|Yes| Run["Run it"] Compare -->|No| Skip["Skip it"]- Source A — every migration file in your migration folders (everything that could run).
- Source B — the
migrationstable in the database, which records everything that has run.
Laravel runs only the files in A that aren’t yet recorded in B. Anything already run — vendor or yours — is skipped, never re-run.
This is exactly why vendor updates are safe: when the author ships a new version, your migrations have already run and are recorded, so they’re skipped. Only the vendor’s new files run — and because you never edited a vendor migration, there’s nothing of yours for their update to clobber.
Two mechanisms, one rule
Section titled “Two mechanisms, one rule”There are only two safe ways to add data, and both live in your own migration files — never in the vendor’s:
| You need to… | Mechanism | Where |
|---|---|---|
Store extra columns on a vendor table (e.g. whitelabel_domain on users) | _zaj migration — an additive migration that adds the columns to the vendor table | database/migrations/{date}_add_{cols}_to_{table}_zaj.php |
| Store a new feature’s own data | zajm_ table — a new table your ZajModule owns | packages/ZajModules/<Module>/Database/Migrations/ → zajm_{module}_{noun} |
The one thing you never do: edit a vendor migration, or run raw ALTER TABLE SQL by hand.
Adding columns to a vendor table — the _zaj migration
Section titled “Adding columns to a vendor table — the _zaj migration”You need a whitelabel_domain field on the vendor’s users table. You do not edit the vendor’s create_users_table migration. You write a separate additive migration with the _zaj suffix that adds the column to users:
// ZAJ:FILE — vendor table extension (_zaj). Additive only; reversible.return new class extends Migration { public function up(): void { Schema::table('users', function (Blueprint $table) { $table->string('whitelabel_domain')->nullable()->after('email') ->comment('ZAJ: ResiDoro v10.4 2026-04-14 — whitelabel domain'); }); } public function down(): void { Schema::table('users', fn (Blueprint $table) => $table->dropColumn('whitelabel_domain')); }};Three markers make the column yours and auditable at every layer:
- Filename — the
_zaj.phpsuffix.find database/migrations -name '*_zaj.php'lists every extension you’ve made. - File header — a
ZAJ:FILEcomment, so a content scan finds it. - Live-schema comment —
->comment('ZAJ: …')lives in the running database itself, so it survives even if the migration file is deleted or a rollback wipes the filesystem markers.
A new feature’s data — a zajm_ table
Section titled “A new feature’s data — a zajm_ table”When the data is genuinely new (not an attribute of a vendor row) — a loyalty ledger, GA4 events — it belongs to a ZajModule, in its own table named zajm_{module}_{noun}. These are brand-new tables the vendor doesn’t know exist, so they carry the lowest risk of all.
zajm_loyalty_points ← your module's table (vendor never touches it) user_id → reference to users.id pointsA module table you fully own can use a normal constrained() foreign key between your own tables. The care comes only when you point out at a volatile vendor row — see soft references below. New zajm_ tables also get a table-level COMMENT 'ZAJ: …'; vendor tables you merely extended with a _zaj column do not (that would misleadingly claim the whole table as yours).
The change-risk ladder
Section titled “The change-risk ladder”Ranked safest → riskiest, with the sanctioned move for each:
| Change | Risk | Sanctioned move |
|---|---|---|
New zajm_ table of your own | Low | Preferred — the vendor doesn’t know it exists |
A zajm_ table that references a vendor row | Medium | Use a soft reference, not a hard FK |
| Add a nullable column to a vendor table | Managed | A marked _zaj migration (additive) + Atlas diff on update |
| Modify or drop a vendor column | Critical | Never — find an additive version of the change |
Soft references — don’t hard-link to a vendor table
Section titled “Soft references — don’t hard-link to a vendor table”When one of your tables points at a vendor row, resist the database-level foreign key. A hard FK creates an ordering dependency: your table now requires the vendor table to exist and keep its shape. If the vendor drops or recreates that table during an update, your migration breaks.
A soft reference — a plain user_id column with an index, but no constrained() foreign key — stores the same link without the dependency. The vendor can reshape their table freely and your migration still runs.
Hard FK → constrained() → breaks if the vendor reshapes the referenced tableSoft ref → indexed column → survives whatever the vendor doesTrade-off: a soft reference means the database won’t enforce the link for you, so your application code keeps it consistent. (Foreign keys between your own zajm_ tables are fine — the dependency is yours to control.)
Verify with the right tool
Section titled “Verify with the right tool”One subtle trap: Laravel’s migration system only knows about migration files. It cannot see a manual SQL change or a raw db.sql import the vendor shipped — it assumes the files tell the whole story.
So use the two tools for what each is good at: use Laravel migrations to make changes, and use a schema inspector (like Atlas) to verify the actual database state and diff your schema against a new vendor release. One makes the change; the other confirms reality matches and flags clashes.
When to use which
Section titled “When to use which”| You want to… | Do this |
|---|---|
| Add a brand-new feature’s data | A zajm_{module}_{noun} table (lowest risk) |
| Store an extra attribute on a vendor row | A marked additive _zaj migration adding the column to the vendor table |
| Point one of your tables at a vendor row | Soft reference (indexed column), not a hard FK |
| Modify or drop a vendor column | Stop — find an additive version of the change |