Skip to content

Instantly share code, notes, and snippets.

@ktarila
Created December 31, 2024 15:50
Show Gist options
  • Select an option

  • Save ktarila/5a34e17e570c79e078bf323a9291d174 to your computer and use it in GitHub Desktop.

Select an option

Save ktarila/5a34e17e570c79e078bf323a9291d174 to your computer and use it in GitHub Desktop.
Rector rule to fix symfony 7.1 automatic mapping of route parameters into Doctrine entities deprecation
<?php
declare(strict_types=1);
namespace Utils\Rector\Rector;
use PhpParser\Node;
use PhpParser\Node\Arg;
use PhpParser\Node\Attribute;
use PhpParser\Node\AttributeGroup;
use PhpParser\Node\Identifier;
use PhpParser\Node\Param;
use PhpParser\Node\Scalar\String_;
use PhpParser\Node\Stmt\ClassMethod;
use Rector\Rector\AbstractRector;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Component\Routing\Attribute\Route;
use Symplify\RuleDocGenerator\ValueObject\CodeSample\CodeSample;
use Symplify\RuleDocGenerator\ValueObject\RuleDefinition;
/**
* @see \Rector\Tests\TypeDeclaration\Rector\AddMapEntityRector\AddMapEntityRectorTest
*/
final class AddMapEntityRector extends AbstractRector
{
public function getRuleDefinition(): RuleDefinition
{
return new RuleDefinition('Add #[MapEntity] attribute to parameters based on Route ID.', [
new CodeSample(
<<<'CODE_SAMPLE'
#[Route('/user/{id}', name: 'user_show', methods: ['GET'])]
public function showUser(
User $user
): Response {
// Controller logic
}
CODE_SAMPLE,
<<<'CODE_SAMPLE'
#[Route('/user/{id}', name: 'user_show', methods: ['GET'])]
public function showUser(
#[MapEntity(id: 'id')] User $user
): Response {
// Controller logic
}
CODE_SAMPLE
),
]);
}
/**
* @return array<class-string<Node>>
*/
public function getNodeTypes(): array
{
// @todo select node type
return [ClassMethod::class];
}
/**
* @param \PhpParser\Node\Stmt\Class_ $node
*/
public function refactor(Node $node): ?Node
{
// Check if the method has a #[Route] attribute
$routeAttribute = null;
foreach ($node->getAttrGroups() as $attrGroup) {
foreach ($attrGroup->attrs as $attribute) {
if ($this->isName($attribute->name, Route::class)) {
$routeAttribute = $attribute;
break;
}
}
}
if (!$routeAttribute) {
return null;
}
// Extract the placeholder from the Route path
$routePath = $routeAttribute->args[0]->value;
preg_match('/\{(\w+)\}/', $routePath->value, $matches);
$routeParameterName = $matches[1] ?? null;
if (!$routeParameterName || 'id' !== $routeParameterName) {
return null;
}
// Add #[MapEntity] to parameters matching the route placeholder
foreach ($node->params as $param) {
if ($param instanceof Param && $this->isAppEntity($param->type)) {
if ($this->hasMapEntityAttribute($param)) {
break;
}
// Create the MapEntity attribute directly as #[MapEntity(id: 'id')]
$args = [new Arg(new String_('id'), name: new Identifier('id'))];
$mapEntityAttribute = new Attribute(
new Node\Name\FullyQualified(MapEntity::class),
$args
);
$param->attrGroups[] = new AttributeGroup([$mapEntityAttribute]);
break;
}
}
return $node;
}
private function isAppEntity(?Node $type): bool
{
if (null === $type) {
return false;
}
return str_starts_with($this->getName($type) ?? '', 'App\Entity\\');
}
private function hasMapEntityAttribute(Param $param): bool
{
// Check if the parameter has the #[MapEntity] attribute
foreach ($param->attrGroups as $attrGroup) {
foreach ($attrGroup->attrs as $attribute) {
if ($this->isName($attribute->name, MapEntity::class)) {
return true; // The attribute exists
}
}
}
return false; // The attribute does not exist
}
}
@PhilETaylor
Copy link

PhilETaylor commented Jul 24, 2025

Junie from phpStorm worked on this for me today, its come up with this, which seems "better" and handles edge cases

<?php

declare(strict_types=1);

namespace App\Utils\Rector;

use PhpParser\Node;
use PhpParser\Node\Arg;
use PhpParser\Node\Attribute;
use PhpParser\Node\AttributeGroup;
use PhpParser\Node\Expr\Array_;
use PhpParser\Node\Expr\ArrayItem;
use PhpParser\Node\Identifier;
use PhpParser\Node\Name;
use PhpParser\Node\Param;
use PhpParser\Node\Scalar\String_;
use PhpParser\Node\Stmt\Class_;
use PhpParser\Node\Stmt\ClassMethod;
use Rector\Contract\Rector\ConfigurableRectorInterface;
use Rector\PostRector\Collector\UseNodesToAddCollector;
use Rector\Rector\AbstractRector;
use Rector\StaticTypeMapper\ValueObject\Type\FullyQualifiedObjectType;
use Rector\ValueObject\PhpVersionFeature;
use Rector\VersionBonding\Contract\MinPhpVersionInterface;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symplify\RuleDocGenerator\ValueObject\CodeSample\ConfiguredCodeSample;
use Symplify\RuleDocGenerator\ValueObject\RuleDefinition;
use Webmozart\Assert\Assert;

/**
 * Configurable Rector rule for adding MapEntity attributes
 */
final class AddMapEntityAttributeRector extends AbstractRector implements MinPhpVersionInterface, ConfigurableRectorInterface
{
    /**
     * @var string[]
     */
    private array $entityNamespaces = [];

    /**
     * @var string[]
     */
    private array $controllerNamespaces = [];

    public function __construct(
        private readonly UseNodesToAddCollector $useNodesToAddCollector
    ) {
    }

    public function getRuleDefinition(): RuleDefinition
    {
        return new RuleDefinition(
            'Add #[MapEntity] attribute to controller method parameters that are Doctrine entities (configurable)',
            [
                new ConfiguredCodeSample(
                    <<<'CODE_SAMPLE'
use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

class UserController extends AbstractController
{
    public function show(User $user): Response
    {
        return $this->render('user/show.html.twig', ['user' => $user]);
    }
}
CODE_SAMPLE
                    ,
                    <<<'CODE_SAMPLE'
use App\Entity\User;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

class UserController extends AbstractController
{
    public function show(#[MapEntity] User $user): Response
    {
        return $this->render('user/show.html.twig', ['user' => $user]);
    }
}
CODE_SAMPLE
                    ,
                    [
                        'entity_namespaces'     => ['App\\Entity'],
                        'controller_namespaces' => ['App\\Controller'],
                    ]
                ),
            ]
        );
    }

    /**
     * @return array<class-string<Node>>
     */
    public function getNodeTypes(): array
    {
        return [Class_::class];
    }

    /**
     * @param Class_ $node
     */
    public function refactor(Node $node): ?Node
    {
        if (! $this->isInConfiguredControllerClass($node)) {
            return null;
        }

        $hasChanges = false;

        foreach ($node->getMethods() as $method) {
            if (! $this->isPublicMethod($method)) {
                continue;
            }

            if (! $this->hasRouteAttribute($method)) {
                continue;
            }

            // Count entity parameters in this method
            $entityParams = [];
            foreach ($method->params as $param) {
                if ($this->shouldAddMapEntityAttribute($param)) {
                    $entityParams[] = $param;
                }
            }

            // Get route path to check for {id} placeholder
            $routePath          = $this->getRoutePathFromMethod($method);
            $hasIdPlaceholder   = $routePath !== null && str_contains($routePath, '{id}');
            $shouldUseIdMapping = $hasIdPlaceholder   && \count($entityParams) === 1;

            foreach ($entityParams as $entityParam) {
                $this->addMapEntityAttribute($entityParam, $shouldUseIdMapping);
                $hasChanges = true;
            }
        }

        if ($hasChanges) {
            $this->addMapEntityImport();
            return $node;
        }

        return null;
    }

    /**
     * @param mixed[] $configuration
     */
    public function configure(array $configuration): void
    {
        $entityNamespaces = $configuration['entity_namespaces'] ?? [];
        Assert::isArray($entityNamespaces);
        Assert::allString($entityNamespaces);
        $this->entityNamespaces = $entityNamespaces;

        $controllerNamespaces = $configuration['controller_namespaces'] ?? [];
        Assert::isArray($controllerNamespaces);
        Assert::allString($controllerNamespaces);
        $this->controllerNamespaces = $controllerNamespaces;
    }

    public function provideMinPhpVersion(): int
    {
        return PhpVersionFeature::ATTRIBUTES;
    }

    private function isPublicMethod(ClassMethod $classMethod): bool
    {
        return $classMethod->isPublic();
    }

    private function hasRouteAttribute(ClassMethod $classMethod): bool
    {
        foreach ($classMethod->attrGroups as $attrGroup) {
            foreach ($attrGroup->attrs as $attr) {
                $attrName = $this->getName($attr->name);
                if ($attrName === 'Route' || $attrName === Route::class) {
                    return true;
                }
            }
        }

        return false;
    }

    private function getRoutePathFromMethod(ClassMethod $classMethod): ?string
    {
        foreach ($classMethod->attrGroups as $attrGroup) {
            foreach ($attrGroup->attrs as $attr) {
                $attrName = $this->getName($attr->name);
                // Check if there are arguments to the Route attribute
                if (($attrName === 'Route' || $attrName === Route::class) && $attr->args !== []) {
                    $firstArg = $attr->args[0];
                    // Handle positional argument (path as first argument)
                    if ($firstArg->value instanceof String_) {
                        return $firstArg->value->value;
                    }
                    // Handle named argument (path: "...")
                    if ($firstArg->name !== null && $this->getName($firstArg->name) === 'path' && $firstArg->value instanceof String_) {
                        return $firstArg->value->value;
                    }
                    // Handle array argument with 'path' key
                    if ($firstArg->value instanceof Array_) {
                        foreach ($firstArg->value->items as $item) {
                            if ($item instanceof ArrayItem && $item->key instanceof String_ && $item->key->value === 'path' && $item->value instanceof String_) {
                                return $item->value->value;
                            }
                        }
                    }
                }
            }
        }

        return null;
    }

    private function isInConfiguredControllerClass(Class_ $class): bool
    {
        $className = $this->getName($class);
        if ($className === null) {
            return false;
        }

        // Check if class is in configured controller namespaces
        foreach ($this->controllerNamespaces as $controllerNamespace) {
            if (str_starts_with($className, $controllerNamespace)) {
                return true;
            }
        }

        // Fallback: check if extends AbstractController
        if ($class->extends instanceof Name) {
            $parentClassName = $this->getName($class->extends);
            if ($parentClassName === AbstractController::class) {
                return true;
            }
        }

        return false;
    }

    private function shouldAddMapEntityAttribute(Param $param): bool
    {
        if ($this->hasMapEntityAttribute($param)) {
            return false;
        }

        if ($this->hasCurrentUserAttribute($param)) {
            return false;
        }

        if (! $param->type instanceof Node) {
            return false;
        }

        $paramType = $this->getName($param->type);
        if ($paramType === null) {
            return false;
        }

        return $this->isConfiguredEntityClass($paramType);
    }

    private function hasMapEntityAttribute(Param $param): bool
    {
        foreach ($param->attrGroups as $attrGroup) {
            foreach ($attrGroup->attrs as $attr) {
                $attrName = $this->getName($attr->name);
                if ($attrName === 'MapEntity' || $attrName === MapEntity::class) {
                    return true;
                }
            }
        }

        return false;
    }

    private function hasCurrentUserAttribute(Param $param): bool
    {
        foreach ($param->attrGroups as $attrGroup) {
            foreach ($attrGroup->attrs as $attr) {
                $attrName = $this->getName($attr->name);
                if ($attrName === 'CurrentUser' || $attrName === CurrentUser::class) {
                    return true;
                }
            }
        }

        return false;
    }

    private function isConfiguredEntityClass(string $className): bool
    {
        foreach ($this->entityNamespaces as $entityNamespace) {
            if (str_starts_with($className, $entityNamespace)) {
                return true;
            }
        }

        return false;
    }

    private function addMapEntityAttribute(Param $param, bool $shouldUseIdMapping = false): void
    {
        if ($shouldUseIdMapping) {
            // Create #[MapEntity(id: 'id')]
            $idArg              = new Arg(new String_('id'), false, false, [], new Identifier('id'));
            $mapEntityAttribute = new Attribute(new Name('MapEntity'), [$idArg]);
        } else {
            // Create #[MapEntity]
            $mapEntityAttribute = new Attribute(new Name('MapEntity'));
        }

        $attributeGroup      = new AttributeGroup([$mapEntityAttribute]);
        $param->attrGroups[] = $attributeGroup;
    }

    private function addMapEntityImport(): void
    {
        $this->useNodesToAddCollector->addUseImport(new FullyQualifiedObjectType(MapEntity::class));
    }
}

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