Statement three of a migration fails. Column name typo. On PostgreSQL the first two ALTERs roll back with it, the schema returns to the exact state before the migration, you fix the typo and run again. On MySQL the first two ALTERs are already permanent.
Same up() method, same php artisan migrate, same green output. The migration tool gives one abstraction over two very different databases. The abstraction covers syntax. It does not cover what happens on failure.
Postgres runs DDL inside transactions. This is a luxury, and most people who grew up on Postgres do not know it is one.
MySQL is another world. Almost every DDL statement performs an implicit commit. The transaction your migration tool politely opened is silently ended before the ALTER even starts. So when statement three fails, the migrations table says the migration did not run. The schema says half of it did. Re-run fails with “column already exists”. Now you are editing the schema by hand on a live database at eleven in the evening. Everyone who has run MySQL migrations knows this exact state. MySQL 8.0 made single statements atomic, which is nice, but it still cannot roll back two committed ALTERs.
What I do about it.
On MySQL, one DDL statement per migration file. It looks pedantic. It means a failed deploy leaves the database in a state the tool can reason about: this migration ran, that one did not. No halves.
Data changes and schema changes never share a migration. Different failure modes, different rollback stories.
Big migrations get a rehearsal. Take a dump with production-sized data, run the migration on staging, note the time and the locks. On Postgres also check that the migration is in fact transactional. A few operations there still are not.
The migration tool is a good clerk. It records what ran and in what order. It does not free you from knowing your database. Nothing does.