Apply module migrations when the Modules folder is outside the app folder

module:migrate hands the module's migrations folder to the migrate command
after removing base_path() from it. Module::getPath() is a realpath(), so on
installations where the Modules folder is a symlink leading outside of the
application folder (a common container layout: /var/www/html/Modules ->
/data/Modules) base_path() does not occur in that path. The value stays
absolute, migrate prepends base_path() to it anyway (BaseCommand::
getMigrationPaths()), the resulting folder does not exist, and the migration
reports "Nothing to migrate" with exit code 0.

Updating a module through the Modules page then leaves the old schema in
place, because Module::updateModule() relies on freescout:module-install and
nothing reports the failure. Activating a module is not affected, as
ModulesController runs a plain migrate afterwards.

Modules now remember the path they have been scanned from, which still
contains the symlink and is therefore relative to base_path(), and the
module:migrate command builds the --path option from it. The scanned path
also travels through the modules cache, as Collection::toArray() keeps the
resolved path only. When neither path is inside the application folder the
command reports it instead of reporting success.

closes #5569
This commit is contained in:
Jörn Bungartz
2026-08-17 10:39:21 -06:00
parent 0f40c152e3
commit 34d2a019b0
3 changed files with 131 additions and 2 deletions

View File

@@ -0,0 +1,96 @@
<?php
/**
* php artisan module:migrate modulename.
*
* Replaces the command from nwidart/laravel-modules, which passes the module's
* migrations folder to the migrate command after removing base_path() from it.
* Module::getPath() is a realpath(), so on installations where the Modules
* folder is a symlink leading outside of the application folder (a common
* container layout: /var/www/html/Modules -> /data/Modules) base_path() does
* not occur in that path. The value stays absolute, migrate prefixes
* base_path() to it anyway (Illuminate\Database\Console\Migrations\
* BaseCommand::getMigrationPaths()), the resulting folder does not exist and
* the migration reports "Nothing to migrate" with a zero exit code.
*
* https://github.com/freescout-help-desk/freescout/issues/5569
*/
namespace App\Console\Commands;
use Nwidart\Modules\Commands\MigrateCommand;
use Nwidart\Modules\Migrations\Migrator;
use Nwidart\Modules\Module;
class ModuleMigrate extends MigrateCommand
{
/**
* Run the migrations of the specified module.
*
* @param Module $module
*/
protected function migrate(Module $module)
{
$path = $this->getMigrationPath($module);
if ($this->option('subpath')) {
$path = $path.'/'.$this->option('subpath');
}
$this->call('migrate', [
'--path' => $path,
'--database' => $this->option('database'),
'--pretend' => $this->option('pretend'),
'--force' => $this->option('force'),
]);
if ($this->option('seed')) {
$this->call('module:seed', ['module' => $module->getName()]);
}
}
/**
* Get the module's migrations folder relative to base_path(), as the
* migrate command prepends base_path() to whatever --path receives.
*
* @param Module $module
*
* @return string
*/
protected function getMigrationPath(Module $module)
{
$absolute_path = (new Migrator($module))->getPath();
$path = str_replace(base_path(), '', $absolute_path);
if ($path != $absolute_path) {
// The module is located inside of the application folder.
return $path;
}
// The module path is a realpath() leading outside of the application
// folder. Rebuild the path from the folder the module has been scanned
// from, which still contains the symlink and is therefore located
// inside of the application folder.
$module_path = $module->getPath();
if (method_exists($module, 'getScannedPath')
&& $module_path
&& strpos($absolute_path, $module_path) === 0
) {
$scanned_path = str_replace(base_path(), '', $module->getScannedPath());
$scanned_path .= substr($absolute_path, strlen($module_path));
if (is_dir(base_path().DIRECTORY_SEPARATOR.$scanned_path)) {
return $scanned_path;
}
}
// Neither path is located inside of the application folder, so the
// migrate command can not resolve it. Tell the user instead of
// reporting "Nothing to migrate".
$this->error('Module migrations folder is located outside of the application folder: '.$absolute_path);
$this->error('Run "php artisan migrate" to apply the module migrations.');
return $path;
}
}

View File

@@ -32,6 +32,15 @@ abstract class Module extends ServiceProvider
*/
protected $path;
/**
* The module path as it has been scanned, before realpath() resolved it.
* When the Modules folder is a symlink leading outside of the application
* folder, this is the only path which is still relative to base_path().
*
* @var string
*/
protected $scanned_path;
/**
* @var array of cached Json objects, keyed by filename
*/
@@ -49,6 +58,7 @@ abstract class Module extends ServiceProvider
parent::__construct($app);
$this->name = $name;
$this->path = realpath($path);
$this->scanned_path = $path;
}
/**
@@ -151,6 +161,16 @@ abstract class Module extends ServiceProvider
return $this->path;
}
/**
* Get the path the module has been scanned from, with symlinks not resolved.
*
* @return string
*/
public function getScannedPath()
{
return $this->scanned_path ?: $this->path;
}
/**
* Set path.
*

View File

@@ -202,7 +202,11 @@ abstract class Repository implements RepositoryInterface, Countable
$modules = [];
foreach ($cached as $name => $module) {
$path = $module['path'];
// Prefer the path the module has been scanned from, with symlinks
// not resolved yet - the constructor resolves it again, so getPath()
// stays the same, but the module also knows its original location.
// Modules cached before this was stored only have 'path'.
$path = !empty($module['scanned_path']) ? $module['scanned_path'] : $module['path'];
$modules[$name] = $this->createModule($this->app, $name, $path);
}
@@ -223,13 +227,22 @@ abstract class Repository implements RepositoryInterface, Countable
return $this->app['cache']->remember($this->config('cache.key'), $this->config('cache.lifetime'), function () {
$modules = $this->scan();
// By some reason when Nwidart\Modules\Module is converted into array
$array = $this->toCollection()->toArray();
$array = (new Collection($modules))->toArray();
// Set `active` flag from DB for each module
foreach ($array as $key => $item) {
if (!empty($item['alias'])) {
$item['active'] = (int) \App\Module::isActive($item['alias']);
}
// Collection::toArray() only keeps the resolved path, but the
// path the module has been scanned from is needed to build
// migration paths relative to base_path().
if (isset($modules[$key]) && method_exists($modules[$key], 'getScannedPath')) {
$array[$key]['scanned_path'] = $modules[$key]->getScannedPath();
}
}
// Remember in memory cache to avoid reading cache file