Managing Laravel migration order core addons then app

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 composer autoload and placed in the standard $paths array.
  • The run() method of the Migrator uses collect($paths)->flatMap(...) and then sortBy('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

  1. 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.
  2. Use Composer scripts
    • Run your custom command after composer install and before the application bootstrap.
  3. 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.
  4. Override the vendor:publish hook
    • 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.

Leave a Comment