Preconditions and environment
- Magento version: 2.4.8-p5 (reproduced; affects any version shipping
magento/magento-composer-installer 0.4.0)
- PHP 8.3, Composer 2.x
- Deploy strategy:
copy (the default for Magento community projects — extra.magento-deploystrategy)
- Affected package:
magento/magento-composer-installer 0.4.0
src/MagentoHackathon/Composer/Magento/Deploystrategy/Copy.php
src/MagentoHackathon/Composer/Magento/DeployManager.php
- A mapped destination (anything in a
magento2-component package's extra.map — e.g. magento/magento2-base maps setup, pub, lib, …) exists as a symlink whose target is not present. This is common with shared persistent storage, e.g.:
pub/media/custom_options -> /shared/pub/media/custom_options (target absent at deploy time)
Steps to reproduce
- Create a fresh project:
composer create-project --repository-url=https://repo.magento.com/ \
magento/project-community-edition=2.4.8-p5 magento-bug
cd magento-bug
find setup -type f | wc -l # 566 — populated correctly on first install
- Replace a mapped destination with a symlink to a non-existent target. Either reproduces it; the
pub/media/custom_options case mirrors a real shared-storage layout:
rm -rf pub/media/custom_options
ln -s /shared/pub/media/custom_options pub/media/custom_options
php -r 'var_dump(is_link("pub/media/custom_options"), file_exists("pub/media/custom_options"));'
# bool(true) bool(false) <-- link node exists, but file_exists() follows it -> false
- Trigger a redeploy of the base package (equivalent to what
composer install runs):
composer reinstall magento/magento2-base
- Inspect
setup/:
find setup -type f | wc -l
Expected result
- A pre-existing symlink at a mapped destination is preserved (not clobbered, not recursed into).
- All other mapped files — including the entire
setup/ directory — are deployed.
- Any genuine deploy error aborts the install (hard fail) with a clear, path-bearing message, instead of passing silently.
Actual result
setup/ is left partially or completely unpopulated (in the custom_options case the whole
setup/ directory is missing — find: setup: No such file or directory).
- No error or warning is printed at default verbosity. Running with
-vvv reveals the hidden
exception:
Origin: Copy.php:103.
Additional information
Root cause
1. Copy::createDelegate() is not symlink-aware (dir-to-dir branch):
// Copy dir to dir
if (file_exists($destPath)) { // dereferences the symlink -> false when target missing
$destPath .= DIRECTORY_SEPARATOR . basename($sourcePath);
}
mkdir($destPath, 0755, true); // link node exists -> "mkdir(): File exists"
file_exists() follows symlinks, so a link to a missing target reads as absent; the guard is
skipped and mkdir() is called on a path that already exists as a link node.
2. DeployManager::doDeploy() swallows the error:
try {
$package->getDeployStrategy()->deploy();
} catch (\ErrorException $e) {
if ($this->io->isDebug()) { // only printed with -vvv
$this->io->write($e->getMessage());
}
}
The \ErrorException unwinds the deploy() loop over getMappings(), so the package's remaining
mappings (including setup/) are never copied — and the failure is invisible unless -vvv is used.
Proposed fix
A PR is open: magento/magento-composer-installer#39. The fix has two parts.
1. Make the copy strategy symlink-aware, and fail with the exact path (Copy.php). A
pre-existing symlink at the destination (e.g. a shared-storage mount) is preserved instead of being
clobbered or mkdir-ed over. A destination that exists but is not a directory (and is not a
symlink) throws an \ErrorException naming the exact path, instead of letting mkdir() emit a
path-less "File exists" warning:
if (file_exists($destPath)) {
$destPath .= DIRECTORY_SEPARATOR . basename($sourcePath);
}
if (is_link($destPath)) {
return true;
}
if (!is_dir($destPath)) {
if (file_exists($destPath)) {
throw new \ErrorException(
sprintf('Cannot deploy to "%s": the path exists but is not a directory', $destPath)
);
}
mkdir($destPath, 0755, true);
}
2. Hard-fail on deploy errors instead of swallowing them (DeployManager.php). A failed file
deploy leaves Magento unable to run, so it must abort the install loudly rather than continue. The
previously-swallowed \ErrorException is re-thrown as a \RuntimeException that names the package,
the underlying message, and the originating file:line, giving composer a non-zero exit:
} catch (\ErrorException $e) {
throw new \RuntimeException(
sprintf(
'Magento deploy failed for "%s": %s (%s:%d)',
$package->getPackageName(),
$e->getMessage(),
$e->getFile(),
$e->getLine()
),
0,
$e
);
}
Note on the earlier draft of this issue, which suggested only logging the error as a <warning> at
normal verbosity: that was changed to a hard fail. A partial deploy produces a broken installation
(e.g. missing setup/), so continuing is incorrect — the install should stop with a clear error.
Verification
Verified on a clean magento/project-community-edition=2.4.8-p5 install (default copy strategy)
and on a real project using shared-storage symlinks:
- Legitimate symlink preserved: a dangling
pub/media/* shared-storage symlink no longer aborts
the deploy. The redeploy completes with no error, setup/ returns to its full file count, and the
symlinks are left in place.
- Genuine failure hard-fails with the exact path: forcing a real filesystem error (e.g. a
read-only setup/) now aborts the install with a non-zero exit and a message such as:
In DeployManager.php line 104:
Magento deploy failed for "magento/magento2-base":
unlink(.../setup/index.php): Permission denied (.../Copy.php:84)
- The change is clean under the Magento2 PHPCS standard (no silenced errors). The remaining
DiscouragedFunction warnings on mkdir/is_dir/is_link/file_exists are pre-existing
throughout this file and unavoidable here — Magento\Framework\Filesystem\DriverInterface is not
available at Composer-plugin runtime.
Release note
No response
Triage and priority
Preconditions and environment
magento/magento-composer-installer0.4.0)copy(the default for Magento community projects —extra.magento-deploystrategy)magento/magento-composer-installer0.4.0src/MagentoHackathon/Composer/Magento/Deploystrategy/Copy.phpsrc/MagentoHackathon/Composer/Magento/DeployManager.phpmagento2-componentpackage'sextra.map— e.g.magento/magento2-basemapssetup,pub,lib, …) exists as a symlink whose target is not present. This is common with shared persistent storage, e.g.:Steps to reproduce
pub/media/custom_optionscase mirrors a real shared-storage layout:composer installruns):setup/:find setup -type f | wc -lExpected result
setup/directory — are deployed.Actual result
setup/is left partially or completely unpopulated (in thecustom_optionscase the wholesetup/directory is missing —find: setup: No such file or directory).-vvvreveals the hiddenexception:
Copy.php:103.Additional information
Root cause
1.
Copy::createDelegate()is not symlink-aware (dir-to-dir branch):file_exists()follows symlinks, so a link to a missing target reads as absent; the guard isskipped and
mkdir()is called on a path that already exists as a link node.2.
DeployManager::doDeploy()swallows the error:The
\ErrorExceptionunwinds thedeploy()loop overgetMappings(), so the package's remainingmappings (including
setup/) are never copied — and the failure is invisible unless-vvvis used.Proposed fix
A PR is open: magento/magento-composer-installer#39. The fix has two parts.
1. Make the copy strategy symlink-aware, and fail with the exact path (
Copy.php). Apre-existing symlink at the destination (e.g. a shared-storage mount) is preserved instead of being
clobbered or
mkdir-ed over. A destination that exists but is not a directory (and is not asymlink) throws an
\ErrorExceptionnaming the exact path, instead of lettingmkdir()emit apath-less
"File exists"warning:2. Hard-fail on deploy errors instead of swallowing them (
DeployManager.php). A failed filedeploy leaves Magento unable to run, so it must abort the install loudly rather than continue. The
previously-swallowed
\ErrorExceptionis re-thrown as a\RuntimeExceptionthat names the package,the underlying message, and the originating
file:line, giving composer a non-zero exit:Verification
Verified on a clean
magento/project-community-edition=2.4.8-p5install (defaultcopystrategy)and on a real project using shared-storage symlinks:
pub/media/*shared-storage symlink no longer abortsthe deploy. The redeploy completes with no error,
setup/returns to its full file count, and thesymlinks are left in place.
read-only
setup/) now aborts the install with a non-zero exit and a message such as:DiscouragedFunctionwarnings onmkdir/is_dir/is_link/file_existsare pre-existingthroughout this file and unavoidable here —
Magento\Framework\Filesystem\DriverInterfaceis notavailable at Composer-plugin runtime.
Release note
No response
Triage and priority