Repository: symfony/maker-bundle (language: php)

Issue:
Use final classes
Hi,
I'm referring to this issue https://github.com/symfony/demo/pull/1409.
Would it be possible to use `final` in classes? For example for controllers, repositories, validators, etc.


Discussion / hints:

Fix patch:
diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php
index c9d94d086..15c718318 100644
--- a/src/DependencyInjection/Configuration.php
+++ b/src/DependencyInjection/Configuration.php
@@ -24,6 +24,8 @@ public function getConfigTreeBuilder(): TreeBuilder
         $rootNode
             ->children()
                 ->scalarNode('root_namespace')->defaultValue('App')->end()
+                ->booleanNode('generate_final_classes')->defaultTrue()->end()
+                ->booleanNode('generate_final_entities')->defaultFalse()->end()
             ->end()
         ;
 
diff --git a/src/DependencyInjection/MakerExtension.php b/src/DependencyInjection/MakerExtension.php
index 51069bcf4..1de775bff 100644
--- a/src/DependencyInjection/MakerExtension.php
+++ b/src/DependencyInjection/MakerExtension.php
@@ -45,6 +45,13 @@ public function load(array $configs, ContainerBuilder $container): void
         $doctrineHelperDefinition = $container->getDefinition('maker.doctrine_helper');
         $doctrineHelperDefinition->replaceArgument(0, $rootNamespace.'\\Entity');
 
+        $componentGeneratorDefinition = $container->getDefinition('maker.template_component_generator');
+        $componentGeneratorDefinition
+            ->replaceArgument(0, $config['generate_final_classes'])
+            ->replaceArgument(1, $config['generate_final_entities'])
+            ->replaceArgument(2, $rootNamespace)
+        ;
+
         $container->registerForAutoconfiguration(MakerInterface::class)
             ->addTag(MakeCommandRegistrationPass::MAKER_TAG);
     }
diff --git a/src/Generator.php b/src/Generator.php
index 03f7984d6..315b33427 100644
--- a/src/Generator.php
+++ b/src/Generator.php
@@ -14,6 +14,7 @@
 use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
 use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException;
 use Symfony\Bundle\MakerBundle\Util\ClassNameDetails;
+use Symfony\Bundle\MakerBundle\Util\ClassSource\Model\ClassData;
 use Symfony\Bundle\MakerBundle\Util\PhpCompatUtil;
 use Symfony\Bundle\MakerBundle\Util\TemplateComponentGenerator;
 
@@ -54,6 +55,11 @@ public function __construct(
      */
     public function generateClass(string $className, string $templateName, array $variables = []): string
     {
+        if (\array_key_exists('class_data', $variables) && $variables['class_data'] instanceof ClassData) {
+            $classData = $this->templateComponentGenerator->configureClass($variables['class_data']);
+            $className = $classData->getFullClassName();
+        }
+
         $targetPath = $this->fileManager->getRelativePathForFutureClass($className);
 
         if (null === $targetPath) {
diff --git a/src/Maker/MakeCrud.php b/src/Maker/MakeCrud.php
index 46facba78..0a37b2ba4 100644
--- a/src/Maker/MakeCrud.php
+++ b/src/Maker/MakeCrud.php
@@ -27,7 +27,7 @@
 use Symfony\Bundle\MakerBundle\Maker\Common\CanGenerateTestsTrait;
 use Symfony\Bundle\MakerBundle\Renderer\FormTypeRenderer;
 use Symfony\Bundle\MakerBundle\Str;
-use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator;
+use Symfony\Bundle\MakerBundle\Util\ClassSource\Model\ClassData;
 use Symfony\Bundle\MakerBundle\Validator;
 use Symfony\Bundle\TwigBundle\TwigBundle;
 use Symfony\Component\Console\Command\Command;
@@ -147,6 +147,21 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen
             ++$iter;
         } while (class_exists($formClassDetails->getFullName()));
 
+        $controllerClassData = ClassData::create(
+            class: \sprintf('Controller\%s', $this->controllerClassName),
+            suffix: 'Controller',
+            extendsClass: AbstractController::class,
+            useStatements: [
+                $entityClassDetails->getFullName(),
+                $formClassDetails->getFullName(),
+                $repositoryClassName,
+                AbstractController::class,
+                Request::class,
+                Response::class,
+                Route::class,
+            ],
+        );
+
         $entityVarPlural = lcfirst($this->inflector->pluralize($entityClassDetails->getShortName()));
         $entityVarSingular = lcfirst($this->inflector->singularize($entityClassDetails->getShortName()));
 
@@ -156,25 +171,15 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen
         $routeName = Str::asRouteName($controllerClassDetails->getRelativeNameWithoutSuffix());
         $templatesPath = Str::asFilePath($controllerClassDetails->getRelativeNameWithoutSuffix());
 
-        $useStatements = new UseStatementGenerator([
-            $entityClassDetails->getFullName(),
-            $formClassDetails->getFullName(),
-            $repositoryClassName,
-            AbstractController::class,
-            Request::class,
-            Response::class,
-            Route::class,
-        ]);
-
         if (EntityManagerInterface::class !== $repositoryClassName) {
-            $useStatements->addUseStatement(EntityManagerInterface::class);
+            $controllerClassData->addUseStatement(EntityManagerInterface::class);
         }
 
         $generator->generateController(
-            $controllerClassDetails->getFullName(),
+            $controllerClassData->getFullClassName(),
             'crud/controller/Controller.tpl.php',
             array_merge([
-                'use_statements' => $useStatements,
+                'class_data' => $controllerClassData,
                 'entity_class_name' => $entityClassDetails->getShortName(),
                 'form_class_name' => $formClassDetails->getShortName(),
                 'route_path' => Str::asRoutePath($controllerClassDetails->getRelativeNameWithoutSuffix()),
@@ -242,37 +247,33 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen
         }
 
         if ($this->shouldGenerateTests()) {
-            $testClassDetails = $generator->createClassNameDetails(
-                $entityClassDetails->getRelativeNameWithoutSuffix(),
-                'Test\\Controller\\',
-                'ControllerTest'
+            $testClassData = ClassData::create(
+                class: \sprintf('Tests\Controller\%s', $entityClassDetails->getRelativeNameWithoutSuffix()),
+                suffix: 'ControllerTest',
+                extendsClass: WebTestCase::class,
+                useStatements: [
+                    $entityClassDetails->getFullName(),
+                    WebTestCase::class,
+                    KernelBrowser::class,
+                    $repositoryClassName,
+                    EntityRepository::class,
+                ],
             );
 
-            $useStatements = new UseStatementGenerator([
-                $entityClassDetails->getFullName(),
-                WebTestCase::class,
-                KernelBrowser::class,
-                $repositoryClassName,
-            ]);
-
-            $useStatements->addUseStatement(EntityRepository::class);
-
             if (EntityManagerInterface::class !== $repositoryClassName) {
-                $useStatements->addUseStatement(EntityManagerInterface::class);
+                $testClassData->addUseStatement(EntityManagerInterface::class);
             }
 
-            $generator->generateFile(
-                'tests/Controller/'.$testClassDetails->getShortName().'.php',
+            $generator->generateClass(
+                $testClassData->getFullClassName(),
                 'crud/test/Test.EntityManager.tpl.php',
                 [
-                    'use_statements' => $useStatements,
+                    'class_data' => $testClassData,
                     'entity_full_class_name' => $entityClassDetails->getFullName(),
                     'entity_class_name' => $entityClassDetails->getShortName(),
                     'entity_var_singular' => $entityVarSingular,
                     'route_path' => Str::asRoutePath($controllerClassDetails->getRelativeNameWithoutSuffix()),
                     'route_name' => $routeName,
-                    'class_name' => Str::getShortClassName($testClassDetails->getFullName()),
-                    'namespace' => Str::getNamespace($testClassDetails->getFullName()),
                     'form_fields' => $entityDoctrineDetails->getFormFields(),
                     'repository_class_name' => EntityManagerInterface::class,
                     'form_field_prefix' => strtolower(Str::asSnakeCase($entityTwigVarSingular)),
diff --git a/src/Maker/MakeVoter.php b/src/Maker/MakeVoter.php
index b807bc36e..faabc993e 100644
--- a/src/Maker/MakeVoter.php
+++ b/src/Maker/MakeVoter.php
@@ -15,10 +15,13 @@
 use Symfony\Bundle\MakerBundle\DependencyBuilder;
 use Symfony\Bundle\MakerBundle\Generator;
 use Symfony\Bundle\MakerBundle\InputConfiguration;
+use Symfony\Bundle\MakerBundle\Util\ClassSource\Model\ClassData;
 use Symfony\Component\Console\Command\Command;
 use Symfony\Component\Console\Input\InputArgument;
 use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
 use Symfony\Component\Security\Core\Authorization\Voter\Voter;
+use Symfony\Component\Security\Core\User\UserInterface;
 
 /**
  * @author Javier Eguiluz <javier.eguiluz@gmail.com>
@@ -46,16 +49,21 @@ public function configureCommand(Command $command, InputConfiguration $inputConf
 
     public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
     {
-        $voterClassNameDetails = $generator->createClassNameDetails(
-            $input->getArgument('name'),
-            'Security\\Voter\\',
-            'Voter'
+        $voterClassData = ClassData::create(
+            class: \sprintf('Security\Voter\%s', $input->getArgument('name')),
+            suffix: 'Voter',
+            extendsClass: Voter::class,
+            useStatements: [
+                TokenInterface::class,
+                Voter::class,
+                UserInterface::class,
+            ]
         );
 
         $generator->generateClass(
-            $voterClassNameDetails->getFullName(),
+            $voterClassData->getFullClassName(),
             'security/Voter.tpl.php',
-            []
+            ['class_data' => $voterClassData]
         );
 
         $generator->writeChanges();
diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml
index ae08be6cb..5fbc0439b 100644
--- a/src/Resources/config/services.xml
+++ b/src/Resources/config/services.xml
@@ -80,6 +80,9 @@
             </service>
 
             <service id="maker.template_component_generator" class="Symfony\Bundle\MakerBundle\Util\TemplateComponentGenerator">
+                <argument /> <!-- generate_final_classes -->
+                <argument /> <!-- generate_final_entities -->
+                <argument /> <!-- root_namespace -->
             </service>
         </services>
 </container>
diff --git a/src/Resources/skeleton/crud/controller/Controller.tpl.php b/src/Resources/skeleton/crud/controller/Controller.tpl.php
index 012ce710f..135d0c10a 100644
--- a/src/Resources/skeleton/crud/controller/Controller.tpl.php
+++ b/src/Resources/skeleton/crud/controller/Controller.tpl.php
@@ -1,11 +1,11 @@
 <?= "<?php\n" ?>
 
-namespace <?= $namespace ?>;
+namespace <?= $class_data->getNamespace() ?>;
 
-<?= $use_statements; ?>
+<?= $class_data->getUseStatements(); ?>
 
 #[Route('<?= $route_path ?>')]
-class <?= $class_name ?> extends AbstractController
+<?= $class_data->getClassDeclaration() ?>
 {
 <?= $generator->generateRouteForControllerMethod('/', sprintf('%s_index', $route_name), ['GET']) ?>
 <?php if (isset($repository_full_class_name)): ?>
diff --git a/src/Resources/skeleton/security/Voter.tpl.php b/src/Resources/skeleton/security/Voter.tpl.php
index 17d3ae5cb..431b3c585 100644
--- a/src/Resources/skeleton/security/Voter.tpl.php
+++ b/src/Resources/skeleton/security/Voter.tpl.php
@@ -1,12 +1,10 @@
 <?= "<?php\n" ?>
 
-namespace <?= $namespace; ?>;
+namespace <?= $class_data->getNamespace(); ?>;
 
-use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
-use Symfony\Component\Security\Core\Authorization\Voter\Voter;
-use Symfony\Component\Security\Core\User\UserInterface;
+<?= $class_data->getUseStatements(); ?>
 
-class <?= $class_name ?> extends Voter
+<?= $class_data->getClassDeclaration() ?>
 {
     public const EDIT = 'POST_EDIT';
     public const VIEW = 'POST_VIEW';
@@ -16,7 +14,7 @@ protected function supports(string $attribute, mixed $subject): bool
         // replace with your own logic
         // https://symfony.com/doc/current/security/voters.html
         return in_array($attribute, [self::EDIT, self::VIEW])
-            && $subject instanceof \App\Entity\<?= str_replace('Voter', null, $class_name) ?>;
+            && $subject instanceof \App\Entity\<?= str_replace('Voter', null, $class_data->getClassName()) ?>;
     }
 
     protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
diff --git a/src/Util/ClassSource/Model/ClassData.php b/src/Util/ClassSource/Model/ClassData.php
new file mode 100644
index 000000000..988404f2a
--- /dev/null
+++ b/src/Util/ClassSource/Model/ClassData.php
@@ -0,0 +1,117 @@
+<?php
+
+/*
+ * This file is part of the Symfony MakerBundle package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Bundle\MakerBundle\Util\ClassSource\Model;
+
+use Symfony\Bundle\MakerBundle\Str;
+use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator;
+
+/**
+ * @author Jesse Rushlow <jr@rushlow.dev>
+ *
+ * @internal
+ */
+final class ClassData
+{
+    private function __construct(
+        private string $className,
+        private string $namespace,
+        public readonly ?string $extends,
+        public readonly bool $isEntity,
+        private UseStatementGenerator $useStatementGenerator,
+        private bool $isFinal = true,
+        private string $rootNamespace = 'App',
+    ) {
+    }
+
+    public static function create(string $class, ?string $suffix = null, ?string $extendsClass = null, bool $isEntity = false, array $useStatements = []): self
+    {
+        $className = Str::getShortClassName($class);
+
+        if (null !== $suffix && !str_ends_with($className, $suffix)) {
+            $className = Str::asClassName(\sprintf('%s%s', $className, $suffix));
+        }
+
+        $useStatements = new UseStatementGenerator($useStatements);
+
+        if ($extendsClass) {
+            $useStatements->addUseStatement($extendsClass);
+        }
+
+        return new self(
+            className: Str::asClassName($className),
+            namespace: Str::getNamespace($class),
+            extends: null === $extendsClass ? null : Str::getShortClassName($extendsClass),
+            isEntity: $isEntity,
+            useStatementGenerator: $useStatements,
+        );
+    }
+
+    public function getClassName(): string
+    {
+        return $this->className;
+    }
+
+    public function getNamespace(): string
+    {
+        if (empty($this->namespace)) {
+            return $this->rootNamespace;
+        }
+
+        return \sprintf('%s\%s', $this->rootNamespace, $this->namespace);
+    }
+
+    public function getFullClassName(): string
+    {
+        return \sprintf('%s\%s', $this->getNamespace(), $this->className);
+    }
+
+    public function setRootNamespace(string $rootNamespace): self
+    {
+        $this->rootNamespace = $rootNamespace;
+
+        return $this;
+    }
+
+    public function getClassDeclaration(): string
+    {
+        $extendsDeclaration = '';
+
+        if (null !== $this->extends) {
+            $extendsDeclaration = \sprintf(' extends %s', $this->extends);
+        }
+
+        return \sprintf('%sclass %s%s',
+            $this->isFinal ? 'final ' : '',
+            $this->className,
+            $extendsDeclaration,
+        );
+    }
+
+    public function setIsFinal(bool $isFinal): self
+    {
+        $this->isFinal = $isFinal;
+
+        return $this;
+    }
+
+    public function addUseStatement(array|string $useStatement): self
+    {
+        $this->useStatementGenerator->addUseStatement($useStatement);
+
+        return $this;
+    }
+
+    public function getUseStatements(): string
+    {
+        return (string) $this->useStatementGenerator;
+    }
+}
diff --git a/src/Util/TemplateComponentGenerator.php b/src/Util/TemplateComponentGenerator.php
index 03af60648..24271fc35 100644
--- a/src/Util/TemplateComponentGenerator.php
+++ b/src/Util/TemplateComponentGenerator.php
@@ -11,6 +11,8 @@
 
 namespace Symfony\Bundle\MakerBundle\Util;
 
+use Symfony\Bundle\MakerBundle\Util\ClassSource\Model\ClassData;
+
 /**
  * @author Jesse Rushlow <jr@rushlow.dev>
  *
@@ -18,6 +20,13 @@
  */
 final class TemplateComponentGenerator
 {
+    public function __construct(
+        private bool $generateFinalClasses,
+        private bool $generateFinalEntities,
+        private string $rootNamespace,
+    ) {
+    }
+
     public function generateRouteForControllerMethod(string $routePath, string $routeName, array $methods = [], bool $indent = true, bool $trailingNewLine = true): string
     {
         $attribute = \sprintf('%s#[Route(\'%s\', name: \'%s\'', $indent ? '    ' : null, $routePath, $routeName);
@@ -43,4 +52,15 @@ public function getPropertyType(ClassNameDetails $classNameDetails): ?string
     {
         return \sprintf('%s ', $classNameDetails->getShortName());
     }
+
+    public function configureClass(ClassData $classMetadata): ClassData
+    {
+        $classMetadata->setRootNamespace($this->rootNamespace);
+
+        if ($classMetadata->isEntity) {
+            return $classMetadata->setIsFinal($this->generateFinalEntities);
+        }
+
+        return $classMetadata->setIsFinal($this->generateFinalClasses);
+    }
 }


Test patch:
diff --git a/src/Resources/skeleton/crud/test/Test.EntityManager.tpl.php b/src/Resources/skeleton/crud/test/Test.EntityManager.tpl.php
index d5aaf90a8..825737383 100644
--- a/src/Resources/skeleton/crud/test/Test.EntityManager.tpl.php
+++ b/src/Resources/skeleton/crud/test/Test.EntityManager.tpl.php
@@ -3,9 +3,9 @@
 
 namespace <?= $namespace ?>;
 
-<?= $use_statements; ?>
+<?= $class_data->getUseStatements(); ?>
 
-class <?= $class_name ?> extends WebTestCase<?= "\n" ?>
+<?= $class_data->getClassDeclaration() ?>
 {
     private KernelBrowser $client;
     private EntityManagerInterface $manager;
diff --git a/tests/Doctrine/EntityRegeneratorTest.php b/tests/Doctrine/EntityRegeneratorTest.php
index 8a095a413..4d2b251cb 100644
--- a/tests/Doctrine/EntityRegeneratorTest.php
+++ b/tests/Doctrine/EntityRegeneratorTest.php
@@ -23,6 +23,7 @@
 use Symfony\Bundle\MakerBundle\Generator;
 use Symfony\Bundle\MakerBundle\Util\AutoloaderUtil;
 use Symfony\Bundle\MakerBundle\Util\MakerFileLinkFormatter;
+use Symfony\Bundle\MakerBundle\Util\TemplateComponentGenerator;
 use Symfony\Component\Config\Loader\LoaderInterface;
 use Symfony\Component\DependencyInjection\ContainerBuilder;
 use Symfony\Component\Filesystem\Filesystem;
@@ -99,7 +100,8 @@ private function doTestRegeneration(string $sourceDir, Kernel $kernel, string $n
 
         $fileManager = new FileManager($fs, $autoloaderUtil, new MakerFileLinkFormatter(null), $tmpDir);
         $doctrineHelper = new DoctrineHelper('App\\Entity', $container->get('doctrine'));
-        $generator = new Generator($fileManager, 'App\\');
+        $templateComponentGenerator = new TemplateComponentGenerator(false, false, 'App');
+        $generator = new Generator(fileManager: $fileManager, namespacePrefix: 'App\\', templateComponentGenerator: $templateComponentGenerator);
         $entityClassGenerator = new EntityClassGenerator($generator, $doctrineHelper);
         $regenerator = new EntityRegenerator(
             $doctrineHelper,
diff --git a/tests/Maker/MakeVoterTest.php b/tests/Maker/MakeVoterTest.php
index c10af66d7..e6494aae2 100644
--- a/tests/Maker/MakeVoterTest.php
+++ b/tests/Maker/MakeVoterTest.php
@@ -14,6 +14,7 @@
 use Symfony\Bundle\MakerBundle\Maker\MakeVoter;
 use Symfony\Bundle\MakerBundle\Test\MakerTestCase;
 use Symfony\Bundle\MakerBundle\Test\MakerTestRunner;
+use Symfony\Component\Yaml\Yaml;
 
 class MakeVoterTest extends MakerTestCase
 {
@@ -32,6 +33,32 @@ public function getTestDetails(): \Generator
                         'FooBar',
                     ]
                 );
+
+                $expectedVoterPath = \dirname(__DIR__).'/fixtures/make-voter/expected/FooBarVoter.php';
+                $generatedVoter = $runner->getPath('src/Security/Voter/FooBarVoter.php');
+
+                self::assertSame(file_get_contents($expectedVoterPath), file_get_contents($generatedVoter));
+            }),
+        ];
+
+        yield 'it_makes_voter_not_final' => [$this->createMakerTest()
+            ->run(function (MakerTestRunner $runner) {
+                $runner->writeFile(
+                    'config/packages/dev/maker.yaml',
+                    Yaml::dump(['when@dev' => ['maker' => ['generate_final_classes' => false]]])
+                );
+
+                $runner->runMaker(
+                    [
+                        // voter class name
+                        'FooBar',
+                    ]
+                );
+
+                $expectedVoterPath = \dirname(__DIR__).'/fixtures/make-voter/expected/not_final_FooBarVoter.php';
+                $generatedVoter = $runner->getPath('src/Security/Voter/FooBarVoter.php');
+
+                self::assertSame(file_get_contents($expectedVoterPath), file_get_contents($generatedVoter));
             }),
         ];
     }
diff --git a/tests/Util/ClassSource/ClassDataTest.php b/tests/Util/ClassSource/ClassDataTest.php
new file mode 100644
index 000000000..cbad75dec
--- /dev/null
+++ b/tests/Util/ClassSource/ClassDataTest.php
@@ -0,0 +1,94 @@
+<?php
+
+/*
+ * This file is part of the Symfony MakerBundle package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Bundle\MakerBundle\Tests\Util\ClassSource;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Bundle\MakerBundle\MakerBundle;
+use Symfony\Bundle\MakerBundle\Test\MakerTestKernel;
+use Symfony\Bundle\MakerBundle\Util\ClassSource\Model\ClassData;
+
+class ClassDataTest extends TestCase
+{
+    public function testStaticConstructor(): void
+    {
+        $meta = ClassData::create(MakerBundle::class);
+
+        // Sanity check in case Maker's NS changes
+        self::assertSame('Symfony\Bundle\MakerBundle\MakerBundle', MakerBundle::class);
+
+        self::assertSame('MakerBundle', $meta->getClassName());
+        self::assertSame('App\Symfony\Bundle\MakerBundle', $meta->getNamespace());
+        self::assertSame('App\Symfony\Bundle\MakerBundle\MakerBundle', $meta->getFullClassName());
+    }
+
+    public function testGetClassDeclaration(): void
+    {
+        $meta = ClassData::create(MakerBundle::class);
+
+        self::assertSame('final class MakerBundle', $meta->getClassDeclaration());
+    }
+
+    public function testIsFinal(): void
+    {
+        $meta = ClassData::create(MakerBundle::class);
+
+        // Default - isFinal - true
+        self::assertSame('final class MakerBundle', $meta->getClassDeclaration());
+
+        // Not Final - isFinal - false
+        $meta->setIsFinal(false);
+        self::assertSame('class MakerBundle', $meta->getClassDeclaration());
+    }
+
+    public function testGetClassDeclarationWithExtends(): void
+    {
+        $meta = ClassData::create(class: MakerBundle::class, extendsClass: MakerTestKernel::class);
+
+        self::assertSame('final class MakerBundle extends MakerTestKernel', $meta->getClassDeclaration());
+    }
+
+    /** @dataProvider suffixDataProvider */
+    public function testSuffix(?string $suffix, string $expectedResult): void
+    {
+        $data = ClassData::create(class: MakerBundle::class, suffix: $suffix);
+
+        self::assertSame($expectedResult, $data->getClassName());
+    }
+
+    public function suffixDataProvider(): \Generator
+    {
+        yield [null, 'MakerBundle'];
+        yield ['Testing', 'MakerBundleTesting'];
+        yield ['Bundle', 'MakerBundle'];
+    }
+
+    /** @dataProvider namespaceDataProvider */
+    public function testNamespace(string $class, ?string $rootNamespace, string $expectedNamespace, string $expectedFullClassName): void
+    {
+        $class = ClassData::create($class);
+
+        if (null !== $rootNamespace) {
+            $class->setRootNamespace($rootNamespace);
+        }
+
+        self::assertSame($expectedNamespace, $class->getNamespace());
+        self::assertSame($expectedFullClassName, $class->getFullClassName());
+    }
+
+    public function namespaceDataProvider(): \Generator
+    {
+        yield ['MyController', null, 'App', 'App\MyController'];
+        yield ['Controller\MyController', null, 'App\Controller', 'App\Controller\MyController'];
+        yield ['MyController', 'Maker', 'Maker', 'Maker\MyController'];
+        yield ['Controller\MyController', 'Maker', 'Maker\Controller', 'Maker\Controller\MyController'];
+    }
+}
diff --git a/tests/Util/TemplateComponentGeneratorTest.php b/tests/Util/TemplateComponentGeneratorTest.php
index 4c756e104..a61bb6bd2 100644
--- a/tests/Util/TemplateComponentGeneratorTest.php
+++ b/tests/Util/TemplateComponentGeneratorTest.php
@@ -12,7 +12,8 @@
 namespace Symfony\Bundle\MakerBundle\Tests\Util;
 
 use PHPUnit\Framework\TestCase;
-use Symfony\Bundle\MakerBundle\Util\PhpCompatUtil;
+use Symfony\Bundle\MakerBundle\MakerBundle;
+use Symfony\Bundle\MakerBundle\Util\ClassSource\Model\ClassData;
 use Symfony\Bundle\MakerBundle\Util\TemplateComponentGenerator;
 
 /**
@@ -22,7 +23,7 @@ class TemplateComponentGeneratorTest extends TestCase
 {
     public function testRouteAttributes(): void
     {
-        $generator = new TemplateComponentGenerator($this->createMock(PhpCompatUtil::class));
+        $generator = new TemplateComponentGenerator(false, false, 'App');
 
         $expected = "    #[Route('/', name: 'app_home')]\n";
 
@@ -34,7 +35,7 @@ public function testRouteAttributes(): void
      */
     public function testRouteMethods(string $expected, array $methods): void
     {
-        $generator = new TemplateComponentGenerator($this->createMock(PhpCompatUtil::class));
+        $generator = new TemplateComponentGenerator(false, false, 'App');
 
         self::assertSame($expected, $generator->generateRouteForControllerMethod(
             '/',
@@ -54,7 +55,7 @@ public function routeMethodDataProvider(): \Generator
      */
     public function testRouteIndentation(string $expected): void
     {
-        $generator = new TemplateComponentGenerator($this->createMock(PhpCompatUtil::class));
+        $generator = new TemplateComponentGenerator(false, false, 'App');
 
         self::assertSame($expected, $generator->generateRouteForControllerMethod(
             '/',
@@ -74,7 +75,7 @@ public function routeIndentationDataProvider(): \Generator
      */
     public function testRouteTrailingNewLine(string $expected): void
     {
-        $generator = new TemplateComponentGenerator($this->createMock(PhpCompatUtil::class));
+        $generator = new TemplateComponentGenerator(false, false, 'App');
 
         self::assertSame($expected, $generator->generateRouteForControllerMethod(
             '/',
@@ -89,4 +90,41 @@ public function routeTrailingNewLineDataProvider(): \Generator
     {
         yield ["#[Route('/', name: 'app_home')]", true];
     }
+
+    /**
+     * @dataProvider finalClassDataProvider
+     */
+    public function testGetFinalClassDeclaration(bool $finalClass, bool $finalEntity, bool $isEntity, string $expectedResult): void
+    {
+        $generator = new TemplateComponentGenerator($finalClass, $finalEntity, 'App');
+
+        $classData = ClassData::create(MakerBundle::class, isEntity: $isEntity);
+
+        $generator->configureClass($classData);
+
+        self::assertSame(\sprintf('%sclass MakerBundle', $expectedResult), $classData->getClassDeclaration());
+    }
+
+    public function finalClassDataProvider(): \Generator
+    {
+        yield 'Not Final Class' => [false, false, false, ''];
+        yield 'Not Final Class w/ Entity' => [false, true, false, ''];
+        yield 'Final Class' => [true, false, false, 'final '];
+        yield 'Final Class w/ Entity' => [true, true, false, 'final '];
+        yield 'Not Final Entity' => [false, false, true, ''];
+        yield 'Not Final Entity w/ Class' => [true, false, true, ''];
+        yield 'Final Entity' => [false, true, true, 'final '];
+        yield 'Final Entity w/ Class' => [true, true, true, 'final '];
+    }
+
+    public function testConfiguresClassDataWithRootNamespace(): void
+    {
+        $generator = new TemplateComponentGenerator(false, false, 'MakerTest');
+
+        $classData = ClassData::create(MakerBundle::class);
+
+        $generator->configureClass($classData);
+
+        self::assertSame('MakerTest\Symfony\Bundle\MakerBundle', $classData->getNamespace());
+    }
 }
diff --git a/tests/fixtures/make-crud/expected/WithCustomRepository.php b/tests/fixtures/make-crud/expected/WithCustomRepository.php
index 390c94bf8..b1c77ffd5 100644
--- a/tests/fixtures/make-crud/expected/WithCustomRepository.php
+++ b/tests/fixtures/make-crud/expected/WithCustomRepository.php
@@ -12,7 +12,7 @@
 use Symfony\Component\Routing\Attribute\Route;
 
 #[Route('/sweet/food')]
-class SweetFoodController extends AbstractController
+final class SweetFoodController extends AbstractController
 {
     #[Route('/', name: 'app_sweet_food_index', methods: ['GET'])]
     public function index(SweetFoodRepository $sweetFoodRepository): Response
diff --git a/tests/fixtures/make-voter/expected/FooBarVoter.php b/tests/fixtures/make-voter/expected/FooBarVoter.php
new file mode 100644
index 000000000..2cfc1ee22
--- /dev/null
+++ b/tests/fixtures/make-voter/expected/FooBarVoter.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace App\Security\Voter;
+
+use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Voter;
+use Symfony\Component\Security\Core\User\UserInterface;
+
+final class FooBarVoter extends Voter
+{
+    public const EDIT = 'POST_EDIT';
+    public const VIEW = 'POST_VIEW';
+
+    protected function supports(string $attribute, mixed $subject): bool
+    {
+        // replace with your own logic
+        // https://symfony.com/doc/current/security/voters.html
+        return in_array($attribute, [self::EDIT, self::VIEW])
+            && $subject instanceof \App\Entity\FooBar;
+    }
+
+    protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
+    {
+        $user = $token->getUser();
+
+        // if the user is anonymous, do not grant access
+        if (!$user instanceof UserInterface) {
+            return false;
+        }
+
+        // ... (check conditions and return true to grant permission) ...
+        switch ($attribute) {
+            case self::EDIT:
+                // logic to determine if the user can EDIT
+                // return true or false
+                break;
+
+            case self::VIEW:
+                // logic to determine if the user can VIEW
+                // return true or false
+                break;
+        }
+
+        return false;
+    }
+}
diff --git a/tests/fixtures/make-voter/expected/not_final_FooBarVoter.php b/tests/fixtures/make-voter/expected/not_final_FooBarVoter.php
new file mode 100644
index 000000000..e38fb4ad2
--- /dev/null
+++ b/tests/fixtures/make-voter/expected/not_final_FooBarVoter.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace App\Security\Voter;
+
+use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Voter;
+use Symfony\Component\Security\Core\User\UserInterface;
+
+class FooBarVoter extends Voter
+{
+    public const EDIT = 'POST_EDIT';
+    public const VIEW = 'POST_VIEW';
+
+    protected function supports(string $attribute, mixed $subject): bool
+    {
+        // replace with your own logic
+        // https://symfony.com/doc/current/security/voters.html
+        return in_array($attribute, [self::EDIT, self::VIEW])
+            && $subject instanceof \App\Entity\FooBar;
+    }
+
+    protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
+    {
+        $user = $token->getUser();
+
+        // if the user is anonymous, do not grant access
+        if (!$user instanceof UserInterface) {
+            return false;
+        }
+
+        // ... (check conditions and return true to grant permission) ...
+        switch ($attribute) {
+            case self::EDIT:
+                // logic to determine if the user can EDIT
+                // return true or false
+                break;
+
+            case self::VIEW:
+                // logic to determine if the user can VIEW
+                // return true or false
+                break;
+        }
+
+        return false;
+    }
+}