Skip to content

Instantly share code, notes, and snippets.

@dbu
Created September 8, 2026 12:07
Show Gist options
  • Select an option

  • Save dbu/aca107c0c42929762d7ddcda6d7703ee to your computer and use it in GitHub Desktop.

Select an option

Save dbu/aca107c0c42929762d7ddcda6d7703ee to your computer and use it in GitHub Desktop.
use asset mapper along with regular assets that need a static version
<?php
declare(strict_types=1);
use Symfony\Component\Asset\PackageInterface;
use Symfony\Component\Asset\PathPackage;
use Symfony\Component\Asset\UrlPackage;
use Symfony\Component\AssetMapper\AssetMapperInterface;
use Symfony\Component\AssetMapper\Path\PublicAssetsPathResolverInterface;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* Workaround to run AssetMapper along with other assets that need a version configured.
*
* This problem was discussed in https://github.com/symfony/symfony/issues/57584 but the issue has been closed
* without a solution.
*
* Replacement for Symfony's own `asset_mapper.asset_package` (MapperAwareAssetPackage), which unconditionally
* forwards to the decorated default package's getUrl() - including its version strategy - even for paths
* AssetMapper itself just resolved to a content-hashed file. With framework.assets.version enabled, that
* stacks a second, static "?%version%" on top of the hash (…-PEf0l1g.js?2.2) and 404s, because the extra
* suffix is not part of the file that was compiled.
*
* Fix: only fall back to the inner (versioned) package for paths AssetMapper does NOT manage. Mapped paths
* are already uniquely versioned by their content hash and are returned as-is.
*
* A path counts as AssetMapper-managed in two cases:
* 1. `assetMapper->getPublicPath($path)` resolves it - $path is a logical path
* (e.g. "@Ui/controller/select_controller.js"), when asset() is called directly.
* 2. $path already starts with the AssetMapper public prefix (e.g. "assets/…") - it is already the resolved,
* hashed physical path, so getPublicPath() (which only indexes logical paths) returns null even though
* this IS an AssetMapper file. This happens for every importmap entry: ImportMapRenderer resolves each
* entry to its hashed path itself, then again runs that hashed path back through this same default
* package (to support subdirectory/CDN prefixing) - so without this second check, every Stimulus
* controller URL gets double-versioned again despite the first check.
*/
final class AssetMapperCompatiblePackage implements PackageInterface
{
private readonly ?string $devServerPrefix;
private readonly string $assetMapperPublicPrefix;
public function __construct(
private readonly PackageInterface $innerPackage,
private readonly AssetMapperInterface $assetMapper,
PublicAssetsPathResolverInterface $publicAssetsPathResolver,
private readonly ?RequestStack $requestStack = null,
?string $devServerPublicPrefix = null,
) {
$this->devServerPrefix = null === $devServerPublicPrefix ? null : '/'.trim($devServerPublicPrefix, '/').'/';
$this->assetMapperPublicPrefix = $publicAssetsPathResolver->resolvePublicPath('');
}
public function getVersion(string $path): string
{
// Already versioned via the content hash baked into the mapped path - no extra version needed.
return $this->isAssetMapperManaged($path) ? '' : $this->innerPackage->getVersion($path);
}
public function getUrl(string $path): string
{
$publicPath = $this->assetMapper->getPublicPath($path);
if (null === $publicPath && !$this->isAssetMapperManaged($path)) {
return $this->innerPackage->getUrl($path);
}
$path = ltrim($publicPath ?? $path, '/');
if (null !== $this->devServerPrefix && str_starts_with('/'.$path, $this->devServerPrefix)) {
// the dev server serves those assets through the kernel, so the front controller must be part of the URL
$request = $this->requestStack?->getMainRequest();
$frontController = $request ? trim(substr($request->getBaseUrl(), \strlen($request->getBasePath())), '/') : '';
if ('' !== $frontController) {
$path = $frontController.'/'.$path;
}
}
// Deliberately does not call $this->innerPackage->getUrl($path): that would re-apply the default
// package's version strategy on top of the hash. Still prepends whatever base_path/base_urls the
// inner package is configured with, without going through its version strategy.
return $this->prependBase($path);
}
private function isAssetMapperManaged(string $path): bool
{
return null !== $this->assetMapper->getPublicPath($path)
|| str_starts_with('/'.ltrim($path, '/'), $this->assetMapperPublicPrefix);
}
/**
* Mirrors PathPackage/UrlPackage::getUrl() minus the version strategy - those are the only two classes
* Symfony's FrameworkExtension ever wires up as the default package. Falls back to a plain root-relative
* path for any other implementation (there is none to mirror version-less prefixing for).
*/
private function prependBase(string $path): string
{
if ($this->innerPackage instanceof UrlPackage) {
return $this->innerPackage->getBaseUrl($path).'/'.$path;
}
if ($this->innerPackage instanceof PathPackage) {
return $this->innerPackage->getBasePath().$path;
}
return '/'.$path;
}
}
@dbu

dbu commented Sep 15, 2026

Copy link
Copy Markdown
Author

FTR: this has been integrated into symfony in a proper way in symfony/symfony#65911

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment