Skip to content

Add AssetMapperBundle base class or trait to simplify UX bundle development #3449

@tacman

Description

@tacman

Summary

Wiring AssetMapper paths in third-party UX bundles requires repetitive, error-prone boilerplate. This proposal suggests adding a base class or trait to Symfony UX (or the HttpKernel bundle) so that bundle authors following standard conventions get correct AssetMapper configuration automatically.

Problem

Every Symfony UX bundle currently needs to implement prependExtension() manually:

public function prependExtension(ContainerConfigurator $container, ContainerBuilder $builder): void
{
    $container->prependExtensionConfig('framework', [
        'asset_mapper' => [
            'paths' => [
                __DIR__.'/../../assets' => '@survos/ux-calendar-bundle',
            ],
        ],
    ]);
}

This is fragile because:

  • The relative path (/../../assets/dist) must be maintained manually per-bundle and is easy to get wrong
  • The asset namespace string is duplicated in multiple places
  • There is no convention enforcement — every bundle author invents their own approach
  • The isAssetMapperAvailable() guard is typically omitted or reimplemented inconsistently

Proposed Solution

A reusable AssetMapperBundle base class (or trait) that encapsulates the standard wiring. Here is a working implementation used in production across several survos/* bundles:

<?php
declare(strict_types=1);

namespace Survos\CoreBundle\Bundle;

use Symfony\Component\AssetMapper\AssetMapperInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symfony\Component\HttpKernel\Bundle\AbstractBundle;

abstract class AssetMapperBundle extends AbstractBundle
{
    public function isAssetMapperAvailable(ContainerBuilder $container): bool
    {
        return interface_exists(AssetMapperInterface::class)
            && $container->hasExtension('framework');
    }

    public function getPaths(): array
    {
        $dir = realpath($this->getPath().'/assets');
        assert($dir && file_exists($dir), 'assets path must exist: '.$this->getPath());
        return [$dir => $this->getAssetNamespace()];
    }

    public function getAssetNamespace(): string
    {
        // Explicit constant takes highest priority
        if (defined('static::ASSET_NAMESPACE')) {
            return static::ASSET_NAMESPACE;
        }

        // Derive from ASSET_PACKAGE constant if defined
        if (defined('static::ASSET_PACKAGE')) {
            $package = static::ASSET_PACKAGE;
            if (str_starts_with($package, '@')) {
                return $package;
            }
            $package = preg_replace('#^survos/#', '', $package) ?? $package;
            return '@survos/'.trim($package, '/');
        }

        // Fall back to deriving namespace from the bundle class name
        $shortName = (new \ReflectionClass($this))->getShortName();
        $shortName = preg_replace('/^Survos/', '', $shortName) ?? $shortName;
        $shortName = preg_replace('/Bundle$/', '', $shortName) ?? $shortName;
        $slug = strtolower((string) preg_replace('/(?<!^)[A-Z]/', '-$0', $shortName));

        return '@survos/'.$slug;
    }

    public function prependExtension(ContainerConfigurator $container, ContainerBuilder $builder): void
    {
        if (!$this->isAssetMapperAvailable($builder)) {
            return;
        }

        $builder->prependExtensionConfig('framework', [
            'asset_mapper' => [
                'paths' => $this->getPaths(),
            ],
        ]);
    }
}

With this in place, a new UX bundle becomes:

class SurvosUxCalendarBundle extends AssetMapperBundle
{
    public const ASSET_PACKAGE = 'ux-calendar';
}

That's it. No manual path wiring, no duplicated namespace strings.

Conventions assumed

Convention Value
Assets directory <BundleRoot>/assets/
Namespace derivation From ASSET_NAMESPACE, then ASSET_PACKAGE, then class name
Namespace format @vendor/package-name

All of these are overridable by implementing the relevant method or constant.

Why a base class rather than a trait?

A trait would also work and may be preferable if bundle authors need to extend a different base class. Both approaches are viable — the key value is in the shared, tested logic for path resolution and namespace derivation.

Prior art / references

  • This pattern is already in use across survos/* bundles in production
  • Related: Symfony UX contribution guide
  • The symfony-ux keyword requirement in composer.json for auto-discovery of UX controllers is a related underdocumented convention that this base class could help enforce or document

Impact

Low risk — this is purely additive. Existing bundles are unaffected. Third-party bundle authors gain a well-tested, conventional starting point that reduces a common source of subtle misconfiguration.

Metadata

Metadata

Assignees

No one assigned

    Labels

    RFCRFC = Request For Comments (proposals about features that you want to be discussed)

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions