Skip to content
LibxaFrame
Engineering 12 August 2026 5 min read

A package could not ship a migration, and nothing said so

loadMigrationsFrom() was guarded by a check for a binding nothing ever created, so it silently did nothing. Every package following the documented API shipped a migration that could never run. Found by building one.

Libxa Secure ships a migration for its audit table. It never ran, and nothing anywhere said why.

That turned out to be a framework bug affecting every package author, and it had been there the whole time because nobody had written a package with a migration before.

The symptom

composer require libxa/secure
php libxa migrate
Running migrations...
Nothing to migrate.

The table did not exist. secure:status reported it as not migrated, which was at least honest, but migrate had nothing to say at all: not an error, not a warning, not a mention of the package.

Three things had to line up

One. The documented way for a package to register migrations is loadMigrationsFrom() in its service provider, which the framework provides:

protected function loadMigrationsFrom(string $path): void
{
    if ($this->app->has('migrator')) {
        $this->app->make('migrator')->addPath($path);
    }
}

Nothing ever bound migrator. The guard was always false, so the method silently did nothing, always, for everyone. A package could call the documented API correctly and get no behaviour and no complaint.

Guards like that are how a method becomes decorative. The condition is there to be defensive, the thing it defends against turns out to be permanent, and the whole body is dead code that looks alive.

Two. Even with the binding in place, the migrate command built its own:

$migrator = new Migrator();
$migrator->addPath($this->app->basePath('src/database/migrations'));

A fresh instance, so anything a service provider had registered during boot was discarded before the command ever looked at it. Two independent reasons for the same nothing.

Three. addPath() appended unconditionally. Once several places contributed paths, which is the whole point of the fix, a repeated path meant running every migration in it twice.

The fix

migrator is now a shared binding created by the database provider, with the application's own path already added. The command resolves it instead of constructing one. addPath() ignores a path it already holds.

Released as 0.10.2, and libxa/secure requires ^0.10.2 for exactly this reason: on anything earlier its table is silently never created and the package looks broken for a reason that is not in its repository.

Why it survived

Nothing in the framework's own test suite ships a migration from a package, because until now no package did. The application's migrations come from the default path, which always worked. Modules were scanned explicitly, which also worked. The one path nobody exercised was the one the documentation recommended.

That is the ordinary shape of this kind of bug: not complicated, not subtle, just never run. It took writing a real package to find, which is a decent argument for building on your own framework before telling other people to.

Keep reading