Summary
When designing a modular CMS on Laravel, the default migration order defined by filename timestamps prevents the desired core → addons → app sequence. Overriding Laravel’s migration runner without exposing core logic to the application is challenging because the framework expects no custom sorting logic.
Root Cause
- Laravel’s Migrator sorts migrations purely by the timestamp prefix of the filenames.
- All migrations from installed packages are discovered via
composerautoload and placed in the standard$pathsarray. - The
run()method of the Migrator usescollect($paths)->flatMap(...)and thensortBy('timestamp'), leaving no hook to inject custom precedence. - Extending the Migrator and binding it in a Service Provider does not replace the load phase because Laravel resolves the migrator before the provider’s binding is applied for artisan commands like
migrate.
Why This Happens in Real Systems
- Package isolation: Packages should be drop‑in, so Laravel expects them to follow the same folder/filename convention.
- No public API: The sorting logic resides in a private method; there is no public hook or configuration.
- Command bus execution order: Artisan commands are resolved at the time of execution, bypassing late bindings.
Real-World Impact
- Schema inconsistencies: Important core tables may be created after addon tables that depend on them, leading to foreign‑key errors.
- Longer migration runs: Addons must wait for all core migrations, even if they don’t rely on them.
- Developer frustration: Modifying timestamps manually for each upgrade is error‑prone and increases maintenance overhead.
Example or Code (if necessary and relevant)
// Custom Migrator that would sort by group priority if it could be used.
class Migrator extends \Illuminate\Database\Migrations\Migrator
{
public function run(array $paths = [], array $options = []): array
{
// attempt to re-order files before running
$migrations = $this->getMigrationFiles($paths);
$sorted = $this->customSort($migrations);
// ... actual migration execution
}
}
(Only the executable class definition is shown; documentation and comments are omitted per the rules.)
How Senior Engineers Fix It
- Create a dedicated migration runner
- Write a command that explicitly loads core, addon, and app migrations in the desired order and invokes the original Migrator on each subset.
- Use Composer scripts
- Run your custom command after
composer installand before the application bootstrap.
- Run your custom command after
- Namespace the migrations
- Place core, addon, and app migrations in distinguishable directories (e.g.,
database/migrations/core,addons/*/database/migrations,app/database/migrations) and pass those directories in the custom command.
- Place core, addon, and app migrations in distinguishable directories (e.g.,
- Override the
vendor:publishhook- Ensure migrations are published to the correct paths at development time, keeping the core logic inside the package.
Example skeleton of a custom runner:
// bin/laravel-migrate-custom.php
make(Illuminate\Contracts\Console\Kernel::class);
$kernel->bootstrap();
$migrator = $app->make(Illuminate\Database\Migrations\Migrator::class);
$paths = [
database_path('migrations/core'),
database_path('migrations/addons'),
database_path('migrations/app'),
];
$migrator->run($paths);
Why Juniors Miss It
- Assuming all migrations can be controlled via timestamps – they overlook Laravel’s package loading mechanism.
- Over‑reliance on extending classes – they bind the custom migrator but ignore that Laravel’s command bus resolves the original instance much earlier.
- Neglecting the separation of concerns – they think they can’t touch core logic, but a custom runner keeps core logic untouched while offering the required order.