Repository: FriendsOfPHP/PHP-CS-Fixer (php)

Issue:
PhpdocOrderFixer - order?
Current PhpdocOrderFixer produce the following order:

``` php
/**
 * @param
 *
 * @throws
 *
 * @return
 */
```

What is the reason of keeping that order, not the param-return-throws ? Is is standardized by PSR? 


Pull request:
feature: Introduce configurability to PhpdocOrderFixer
Closes #1602.

Introduce configurability to PhpdocOrderFixer with option ``order`` which explicitly defines sequence of annotations.

Option ``order`` accepts array of strings.
Default value is  ``['param', 'throws', 'return']``.

Patch:
diff --git a/doc/list.rst b/doc/list.rst
index 6b430c8a4..c38f41be4 100644
--- a/doc/list.rst
+++ b/doc/list.rst
@@ -2028,9 +2028,17 @@ List of Available Rules
    `Source PhpCsFixer\\Fixer\\Phpdoc\\PhpdocNoUselessInheritdocFixer <./../src/Fixer/Phpdoc/PhpdocNoUselessInheritdocFixer.php>`_
 -  `phpdoc_order <./rules/phpdoc/phpdoc_order.rst>`_
 
-   Annotations in PHPDoc should be ordered so that ``@param`` annotations come first, then ``@throws`` annotations, then ``@return`` annotations.
+   Annotations in PHPDoc should be ordered in defined sequence.
 
-   Part of rule set `@PhpCsFixer <./ruleSets/PhpCsFixer.rst>`_
+   Configuration options:
+
+   - | ``order``
+     | Sequence in which annotations in PHPDoc should be ordered.
+     | Allowed types: ``string[]``
+     | Default value: ``['param', 'throws', 'return']``
+
+
+   Part of rule sets `@PhpCsFixer <./ruleSets/PhpCsFixer.rst>`_ `@Symfony <./ruleSets/Symfony.rst>`_
 
    `Source PhpCsFixer\\Fixer\\Phpdoc\\PhpdocOrderFixer <./../src/Fixer/Phpdoc/PhpdocOrderFixer.php>`_
 -  `phpdoc_order_by_value <./rules/phpdoc/phpdoc_order_by_value.rst>`_
diff --git a/doc/ruleSets/Symfony.rst b/doc/ruleSets/Symfony.rst
index 14c888c0a..94ab0d335 100644
--- a/doc/ruleSets/Symfony.rst
+++ b/doc/ruleSets/Symfony.rst
@@ -95,6 +95,9 @@ Rules
 - `phpdoc_no_alias_tag <./../rules/phpdoc/phpdoc_no_alias_tag.rst>`_
 - `phpdoc_no_package <./../rules/phpdoc/phpdoc_no_package.rst>`_
 - `phpdoc_no_useless_inheritdoc <./../rules/phpdoc/phpdoc_no_useless_inheritdoc.rst>`_
+- `phpdoc_order <./../rules/phpdoc/phpdoc_order.rst>`_
+  config:
+  ``['order' => ['param', 'return', 'throws']]``
 - `phpdoc_return_self_reference <./../rules/phpdoc/phpdoc_return_self_reference.rst>`_
 - `phpdoc_scalar <./../rules/phpdoc/phpdoc_scalar.rst>`_
 - `phpdoc_separation <./../rules/phpdoc/phpdoc_separation.rst>`_
diff --git a/doc/rules/index.rst b/doc/rules/index.rst
index 24f6a07a6..b18ffe2fe 100644
--- a/doc/rules/index.rst
+++ b/doc/rules/index.rst
@@ -690,7 +690,7 @@ PHPDoc
   Order phpdoc tags by value.
 - `phpdoc_order <./phpdoc/phpdoc_order.rst>`_
 
-  Annotations in PHPDoc should be ordered so that ``@param`` annotations come first, then ``@throws`` annotations, then ``@return`` annotations.
+  Annotations in PHPDoc should be ordered in defined sequence.
 - `phpdoc_return_self_reference <./phpdoc/phpdoc_return_self_reference.rst>`_
 
   The type of ``@return`` annotations of methods returning a reference to itself must the configured one.
diff --git a/doc/rules/phpdoc/phpdoc_order.rst b/doc/rules/phpdoc/phpdoc_order.rst
index 4086b44fc..828c067f7 100644
--- a/doc/rules/phpdoc/phpdoc_order.rst
+++ b/doc/rules/phpdoc/phpdoc_order.rst
@@ -2,8 +2,19 @@
 Rule ``phpdoc_order``
 =====================
 
-Annotations in PHPDoc should be ordered so that ``@param`` annotations come
-first, then ``@throws`` annotations, then ``@return`` annotations.
+Annotations in PHPDoc should be ordered in defined sequence.
+
+Configuration
+-------------
+
+``order``
+~~~~~~~~~
+
+Sequence in which annotations in PHPDoc should be ordered.
+
+Allowed types: ``string[]``
+
+Default value: ``['param', 'throws', 'return']``
 
 Examples
 --------
@@ -11,6 +22,8 @@ Examples
 Example #1
 ~~~~~~~~~~
 
+*Default* configuration.
+
 .. code-block:: diff
 
    --- Original
@@ -28,10 +41,82 @@ Example #1
    + * @return int  Return the number of changes.
      */
 
+Example #2
+~~~~~~~~~~
+
+With configuration: ``['order' => ['param', 'throws', 'return']]``.
+
+.. code-block:: diff
+
+   --- Original
+   +++ New
+    <?php
+    /**
+     * Hello there!
+     *
+   - * @throws Exception|RuntimeException foo
+     * @custom Test!
+   - * @return int  Return the number of changes.
+     * @param string $foo
+     * @param bool   $bar Bar
+   + * @throws Exception|RuntimeException foo
+   + * @return int  Return the number of changes.
+     */
+
+Example #3
+~~~~~~~~~~
+
+With configuration: ``['order' => ['param', 'return', 'throws']]``.
+
+.. code-block:: diff
+
+   --- Original
+   +++ New
+    <?php
+    /**
+     * Hello there!
+     *
+   - * @throws Exception|RuntimeException foo
+     * @custom Test!
+   - * @return int  Return the number of changes.
+     * @param string $foo
+     * @param bool   $bar Bar
+   + * @return int  Return the number of changes.
+   + * @throws Exception|RuntimeException foo
+     */
+
+Example #4
+~~~~~~~~~~
+
+With configuration: ``['order' => ['param', 'custom', 'throws', 'return']]``.
+
+.. code-block:: diff
+
+   --- Original
+   +++ New
+    <?php
+    /**
+     * Hello there!
+     *
+   + * @param string $foo
+   + * @param bool   $bar Bar
+   + * @custom Test!
+     * @throws Exception|RuntimeException foo
+   - * @custom Test!
+     * @return int  Return the number of changes.
+   - * @param string $foo
+   - * @param bool   $bar Bar
+     */
+
 Rule sets
 ---------
 
-The rule is part of the following rule set:
+The rule is part of the following rule sets:
 
 @PhpCsFixer
-  Using the `@PhpCsFixer <./../../ruleSets/PhpCsFixer.rst>`_ rule set will enable the ``phpdoc_order`` rule.
+  Using the `@PhpCsFixer <./../../ruleSets/PhpCsFixer.rst>`_ rule set will enable the ``phpdoc_order`` rule with the default config.
+
+@Symfony
+  Using the `@Symfony <./../../ruleSets/Symfony.rst>`_ rule set will enable the ``phpdoc_order`` rule with the config below:
+
+  ``['order' => ['param', 'return', 'throws']]``
diff --git a/src/Fixer/Phpdoc/PhpdocOrderFixer.php b/src/Fixer/Phpdoc/PhpdocOrderFixer.php
index b9eac52a4..5883cf050 100644
--- a/src/Fixer/Phpdoc/PhpdocOrderFixer.php
+++ b/src/Fixer/Phpdoc/PhpdocOrderFixer.php
@@ -16,35 +16,35 @@
 
 use PhpCsFixer\AbstractFixer;
 use PhpCsFixer\DocBlock\DocBlock;
+use PhpCsFixer\Fixer\ConfigurableFixerInterface;
+use PhpCsFixer\FixerConfiguration\FixerConfigurationResolver;
+use PhpCsFixer\FixerConfiguration\FixerConfigurationResolverInterface;
+use PhpCsFixer\FixerConfiguration\FixerOptionBuilder;
 use PhpCsFixer\FixerDefinition\CodeSample;
 use PhpCsFixer\FixerDefinition\FixerDefinition;
 use PhpCsFixer\FixerDefinition\FixerDefinitionInterface;
 use PhpCsFixer\Tokenizer\Token;
 use PhpCsFixer\Tokenizer\Tokens;
+use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException;
 
 /**
  * @author Graham Campbell <hello@gjcampbell.co.uk>
+ * @author Jakub Kwaśniewski <jakub@zero-85.pl>
  */
-final class PhpdocOrderFixer extends AbstractFixer
+final class PhpdocOrderFixer extends AbstractFixer implements ConfigurableFixerInterface
 {
     /**
-     * {@inheritdoc}
+     * @const string[]
      */
-    public function isCandidate(Tokens $tokens): bool
-    {
-        return $tokens->isTokenKindFound(T_DOC_COMMENT);
-    }
+    private const ORDER_DEFAULT = ['param', 'throws', 'return'];
 
     /**
      * {@inheritdoc}
      */
     public function getDefinition(): FixerDefinitionInterface
     {
-        return new FixerDefinition(
-            'Annotations in PHPDoc should be ordered so that `@param` annotations come first, then `@throws` annotations, then `@return` annotations.',
-            [
-                new CodeSample(
-                    '<?php
+        $code = <<<'EOF'
+<?php
 /**
  * Hello there!
  *
@@ -54,12 +54,28 @@ public function getDefinition(): FixerDefinitionInterface
  * @param string $foo
  * @param bool   $bar Bar
  */
-'
-                ),
-            ]
+
+EOF;
+
+        return new FixerDefinition(
+            'Annotations in PHPDoc should be ordered in defined sequence.',
+            [
+                new CodeSample($code),
+                new CodeSample($code, ['order' => self::ORDER_DEFAULT]),
+                new CodeSample($code, ['order' => ['param', 'return', 'throws']]),
+                new CodeSample($code, ['order' => ['param', 'custom', 'throws', 'return']]),
+            ],
         );
     }
 
+    /**
+     * {@inheritdoc}
+     */
+    public function isCandidate(Tokens $tokens): bool
+    {
+        return $tokens->isTokenKindFound(T_DOC_COMMENT);
+    }
+
     /**
      * {@inheritdoc}
      *
@@ -71,6 +87,26 @@ public function getPriority(): int
         return -2;
     }
 
+    /**
+     * {@inheritdoc}
+     */
+    protected function createConfigurationDefinition(): FixerConfigurationResolverInterface
+    {
+        return new FixerConfigurationResolver([
+            (new FixerOptionBuilder('order', 'Sequence in which annotations in PHPDoc should be ordered.'))
+                ->setAllowedTypes(['string[]'])
+                ->setAllowedValues([function ($order) {
+                    if (\count($order) < 2) {
+                        throw new InvalidOptionsException('The option "order" value is invalid. Minimum two tags are required.');
+                    }
+
+                    return true;
+                }])
+                ->setDefault(self::ORDER_DEFAULT)
+                ->getOption(),
+        ]);
+    }
+
     /**
      * {@inheritdoc}
      */
@@ -81,38 +117,51 @@ protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
                 continue;
             }
 
+            // assuming annotations are already grouped by tags
             $content = $token->getContent();
-            // move param to start, return to end, leave throws in the middle
-            $content = $this->moveParamAnnotations($content);
-            // we're parsing the content again to make sure the internal
+
+            // sort annotations
+            $successors = $this->configuration['order'];
+            while (\count($successors) >= 3) {
+                $predecessor = array_shift($successors);
+                $content = $this->moveAnnotationsBefore($predecessor, $successors, $content);
+            }
+
+            // we're parsing the content last time to make sure the internal
             // state of the docblock is correct after the modifications
-            $content = $this->moveReturnAnnotations($content);
+            $predecessors = $this->configuration['order'];
+            $last = array_pop($predecessors);
+            $content = $this->moveAnnotationsAfter($last, $predecessors, $content);
+
             // persist the content at the end
             $tokens[$index] = new Token([T_DOC_COMMENT, $content]);
         }
     }
 
     /**
-     * Move all param annotations in before throws and return annotations.
+     * Move all given annotations in before given set of annotations.
+     *
+     * @param string   $move   Tag of annotations that should be moved
+     * @param string[] $before Tags of annotations that should moved annotations be placed before
      */
-    private function moveParamAnnotations(string $content): string
+    private function moveAnnotationsBefore(string $move, array $before, string $content): string
     {
         $doc = new DocBlock($content);
-        $params = $doc->getAnnotationsOfType('param');
+        $toBeMoved = $doc->getAnnotationsOfType($move);
 
-        // nothing to do if there are no param annotations
-        if (0 === \count($params)) {
+        // nothing to do if there are no annotations to be moved
+        if (0 === \count($toBeMoved)) {
             return $content;
         }
 
-        $others = $doc->getAnnotationsOfType(['throws', 'return']);
+        $others = $doc->getAnnotationsOfType($before);
 
         if (0 === \count($others)) {
             return $content;
         }
 
-        // get the index of the final line of the final param annotation
-        $end = end($params)->getEnd();
+        // get the index of the final line of the final toBoMoved annotation
+        $end = end($toBeMoved)->getEnd();
 
         $line = $doc->getLine($end);
 
@@ -129,27 +178,30 @@ private function moveParamAnnotations(string $content): string
     }
 
     /**
-     * Move all return annotations after param and throws annotations.
+     * Move all given annotations after given set of annotations.
+     *
+     * @param string   $move  Tag of annotations that should be moved
+     * @param string[] $after Tags of annotations that should moved annotations be placed after
      */
-    private function moveReturnAnnotations(string $content): string
+    private function moveAnnotationsAfter(string $move, array $after, string $content): string
     {
         $doc = new DocBlock($content);
-        $returns = $doc->getAnnotationsOfType('return');
+        $toBeMoved = $doc->getAnnotationsOfType($move);
 
-        // nothing to do if there are no return annotations
-        if (0 === \count($returns)) {
+        // nothing to do if there are no annotations to be moved
+        if (0 === \count($toBeMoved)) {
             return $content;
         }
 
-        $others = $doc->getAnnotationsOfType(['param', 'throws']);
+        $others = $doc->getAnnotationsOfType($after);
 
         // nothing to do if there are no other annotations
         if (0 === \count($others)) {
             return $content;
         }
 
-        // get the index of the first line of the first return annotation
-        $start = $returns[0]->getStart();
+        // get the index of the first line of the first toBeMoved annotation
+        $start = $toBeMoved[0]->getStart();
         $line = $doc->getLine($start);
 
         // move stuff about if required
diff --git a/src/RuleSet/Sets/SymfonySet.php b/src/RuleSet/Sets/SymfonySet.php
index f52a3b505..c4291a95e 100644
--- a/src/RuleSet/Sets/SymfonySet.php
+++ b/src/RuleSet/Sets/SymfonySet.php
@@ -140,6 +140,13 @@ public function getRules(): array
             'phpdoc_no_alias_tag' => true,
             'phpdoc_no_package' => true,
             'phpdoc_no_useless_inheritdoc' => true,
+            'phpdoc_order' => [
+                'order' => [
+                    'param',
+                    'return',
+                    'throws',
+                ],
+            ],
             'phpdoc_return_self_reference' => true,
             'phpdoc_scalar' => true,
             'phpdoc_separation' => true,


Test patch:
diff --git a/tests/Fixer/Phpdoc/PhpdocOrderFixerTest.php b/tests/Fixer/Phpdoc/PhpdocOrderFixerTest.php
index 04308c979..2cdab8a71 100644
--- a/tests/Fixer/Phpdoc/PhpdocOrderFixerTest.php
+++ b/tests/Fixer/Phpdoc/PhpdocOrderFixerTest.php
@@ -14,10 +14,12 @@
 
 namespace PhpCsFixer\Tests\Fixer\Phpdoc;
 
+use PhpCsFixer\ConfigurationException\InvalidFixerConfigurationException;
 use PhpCsFixer\Tests\Test\AbstractFixerTestCase;
 
 /**
  * @author Graham Campbell <hello@gjcampbell.co.uk>
+ * @author Jakub Kwaśniewski <jakub@zero-85.pl>
  *
  * @internal
  *
@@ -25,6 +27,22 @@
  */
 final class PhpdocOrderFixerTest extends AbstractFixerTestCase
 {
+    public function testEmptyOrderConfiguration(): void
+    {
+        $this->expectException(InvalidFixerConfigurationException::class);
+        $this->expectExceptionMessage('The option "order" value is invalid. Minimum two tags are required.');
+
+        $this->fixer->configure(['order' => []]);
+    }
+
+    public function testInvalidOrderConfiguration(): void
+    {
+        $this->expectException(InvalidFixerConfigurationException::class);
+        $this->expectExceptionMessage('The option "order" value is invalid. Minimum two tags are required.');
+
+        $this->fixer->configure(['order' => ['param']]);
+    }
+
     public function testNoChanges(): void
     {
         $expected = <<<'EOF'
@@ -44,8 +62,15 @@ public function testNoChanges(): void
         $this->doTest($expected);
     }
 
-    public function testOnlyParams(): void
+    /**
+     * @dataProvider provideDifferentOrderCases
+     *
+     * @param array<string, mixed> $config
+     */
+    public function testOnlyParams(array $config): void
     {
+        $this->fixer->configure($config);
+
         $expected = <<<'EOF'
 <?php
     /**
@@ -57,8 +82,15 @@ public function testOnlyParams(): void
         $this->doTest($expected);
     }
 
-    public function testOnlyReturns(): void
+    /**
+     * @dataProvider provideDifferentOrderCases
+     *
+     * @param array<string, mixed> $config
+     */
+    public function testOnlyReturns(array $config): void
     {
+        $this->fixer->configure($config);
+
         $expected = <<<'EOF'
 <?php
     /**
@@ -71,13 +103,26 @@ public function testOnlyReturns(): void
         $this->doTest($expected);
     }
 
-    public function testEmpty(): void
+    /**
+     * @dataProvider provideDifferentOrderCases
+     *
+     * @param array<string, mixed> $config
+     */
+    public function testEmpty(array $config): void
     {
+        $this->fixer->configure($config);
         $this->doTest('/***/');
     }
 
-    public function testNoAnnotations(): void
+    /**
+     * @dataProvider provideDifferentOrderCases
+     *
+     * @param array<string, mixed> $config
+     */
+    public function testNoAnnotations(array $config): void
     {
+        $this->fixer->configure($config);
+
         $expected = <<<'EOF'
 <?php
     /**
@@ -115,7 +160,7 @@ public function testFixBasicCase(): void
         $this->doTest($expected, $input);
     }
 
-    public function testFixCompeteCase(): void
+    public function testFixCompleteCase(): void
     {
         $expected = <<<'EOF'
 <?php
@@ -208,4 +253,571 @@ public function testExampleFromSymfony(): void
 
         $this->doTest($expected, $input);
     }
+
+    public function testNoChangesWithLaravelStyle(): void
+    {
+        $this->fixer->configure(['order' => ['param', 'return', 'throws']]);
+
+        $expected = <<<'EOF'
+<?php
+    /**
+     * Do some cool stuff.
+     *
+     * @param EngineInterface $templating
+     * @param string          $name
+     *
+     * @return void|bar
+     *
+     * @throws Exception
+     */
+
+EOF;
+        $this->doTest($expected);
+    }
+
+    public function testFixBasicCaseWithLaravelStyle(): void
+    {
+        $this->fixer->configure(['order' => ['param', 'return', 'throws']]);
+
+        $expected = <<<'EOF'
+<?php
+    /**
+     * @param string $foo
+     * @return bool
+     * @throws Exception
+     */
+
+EOF;
+
+        $input = <<<'EOF'
+<?php
+    /**
+     * @throws Exception
+     * @return bool
+     * @param string $foo
+     */
+
+EOF;
+
+        $this->doTest($expected, $input);
+    }
+
+    public function testFixCompleteCaseWithLaravelStyle(): void
+    {
+        $this->fixer->configure(['order' => ['param', 'return', 'throws']]);
+
+        $expected = <<<'EOF'
+<?php
+    /**
+     * Hello there!
+     *
+     * Long description
+     * goes here.
+     *
+     * @internal
+     *
+     *
+     * @custom Test!
+     *         asldnaksdkjasdasd
+     *
+     *
+     *
+     * @param string $foo
+     * @param bool   $bar Bar
+     * @return bool Return false on failure.
+     * @return int  Return the number of changes.
+     * @throws Exception|RuntimeException dfsdf
+     *         jkaskdnaksdnkasndansdnansdajsdnkasd
+     */
+
+EOF;
+
+        $input = <<<'EOF'
+<?php
+    /**
+     * Hello there!
+     *
+     * Long description
+     * goes here.
+     *
+     * @internal
+     *
+     * @throws Exception|RuntimeException dfsdf
+     *         jkaskdnaksdnkasndansdnansdajsdnkasd
+     *
+     * @custom Test!
+     *         asldnaksdkjasdasd
+     *
+     *
+     * @return bool Return false on failure.
+     * @return int  Return the number of changes.
+     *
+     * @param string $foo
+     * @param bool   $bar Bar
+     */
+
+EOF;
+
+        $this->doTest($expected, $input);
+    }
+
+    public function testExampleFromSymfonyWithLaravelStyle(): void
+    {
+        $this->fixer->configure(['order' => ['param', 'return', 'throws']]);
+
+        $input = <<<'EOF'
+<?php
+    /**
+     * Renders a template.
+     *
+     * @param mixed $name       A template name
+     * @param array $parameters An array of parameters to pass to the template
+     *
+     * @return string The evaluated template as a string
+     *
+     * @throws \InvalidArgumentException if the template does not exist
+     * @throws \RuntimeException         if the template cannot be rendered
+     */
+
+EOF;
+
+        $this->doTest($input);
+    }
+
+    /**
+     * @return array<string, mixed>[][]
+     */
+    public function provideDifferentOrderCases(): array
+    {
+        return [
+            [['order' => ['param', 'throw', 'return']]],
+            [['order' => ['param', 'return', 'throw']]],
+        ];
+    }
+
+    /**
+     * @dataProvider provideBasicCodeWithDifferentOrdersCases
+     *
+     * @param array<string, mixed> $config
+     */
+    public function testFixBasicCaseWithDifferentOrders(array $config, string $expected, ?string $input): void
+    {
+        $this->fixer->configure($config);
+
+        $this->doTest($expected, $input);
+    }
+
+    /**
+     * @return array<array<null|array<string, mixed>|string>>
+     */
+    public function provideBasicCodeWithDifferentOrdersCases(): array
+    {
+        $input = <<<'EOF'
+<?php
+    /**
+     * @throws Exception
+     * @return bool
+     * @param string $foo
+     */
+
+EOF;
+
+        return [
+            [
+                ['order' => ['return', 'throws', 'param']],
+                <<<'EOF'
+<?php
+    /**
+     * @return bool
+     * @throws Exception
+     * @param string $foo
+     */
+
+EOF,
+                $input,
+            ],
+
+            [
+                ['order' => ['throws', 'return', 'param']],
+                <<<'EOF'
+<?php
+    /**
+     * @throws Exception
+     * @return bool
+     * @param string $foo
+     */
+
+EOF,
+                null,
+            ],
+        ];
+    }
+
+    public function testFixCompleteCaseWithCustomOrder(): void
+    {
+        $this->fixer->configure(['order' => [
+            'throws',
+            'return',
+            'param',
+            'custom',
+            'internal',
+        ]]);
+
+        $expected = <<<'EOF'
+<?php
+    /**
+     * Hello there!
+     *
+     * Long description
+     * goes here.
+     *
+     *
+     * @throws Exception|RuntimeException dfsdf
+     *         jkaskdnaksdnkasndansdnansdajsdnkasd
+     *
+     *
+     *
+     * @return bool Return false on failure.
+     * @return int  Return the number of changes.
+     *
+     * @param string $foo
+     * @param bool   $bar Bar
+     * @custom Test!
+     *         asldnaksdkjasdasd
+     * @internal
+     */
+
+EOF;
+
+        $input = <<<'EOF'
+<?php
+    /**
+     * Hello there!
+     *
+     * Long description
+     * goes here.
+     *
+     * @internal
+     *
+     * @throws Exception|RuntimeException dfsdf
+     *         jkaskdnaksdnkasndansdnansdajsdnkasd
+     *
+     * @custom Test!
+     *         asldnaksdkjasdasd
+     *
+     *
+     * @return bool Return false on failure.
+     * @return int  Return the number of changes.
+     *
+     * @param string $foo
+     * @param bool   $bar Bar
+     */
+
+EOF;
+
+        $this->doTest($expected, $input);
+    }
+
+    /**
+     * @dataProvider provideCompleteCasesWithCustomOrdersCases
+     *
+     * @param array<string, mixed> $config
+     */
+    public function testFixCompleteCasesWithCustomOrders(array $config, string $expected, string $input): void
+    {
+        $this->fixer->configure($config);
+
+        $this->doTest($expected, $input);
+    }
+
+    /**
+     * @return array<string, array<int, string|string[][]>>
+     */
+    public function provideCompleteCasesWithCustomOrdersCases(): array
+    {
+        return [
+            'intepacuthre' => [
+                ['order' => ['internal', 'template', 'param', 'custom', 'throws', 'return']],
+                <<<'EOF'
+<?php
+    /**
+     * Hello there
+     *
+     * Long description
+     * goes here.
+     *
+     * @internal
+     * @template T of Extension\Extension
+     * @param string $foo
+     * @param bool   $bar Bar
+     * @param class-string<T> $id
+     * @custom Test!
+     *         asldnaksdkjasdasd
+     * @throws Exception|RuntimeException dfsdf
+     *         jkaskdnaksdnkasndansdnansdajsdnkasd
+     * @return bool Return false on failure
+     * @return int  Return the number of changes.
+     **/
+
+EOF,
+                <<<'EOF'
+<?php
+    /**
+     * Hello there
+     *
+     * Long description
+     * goes here.
+     *
+     * @internal
+     * @param string $foo
+     * @param bool   $bar Bar
+     * @param class-string<T> $id
+     * @custom Test!
+     *         asldnaksdkjasdasd
+     * @template T of Extension\Extension
+     * @return bool Return false on failure
+     * @return int  Return the number of changes.
+     * @throws Exception|RuntimeException dfsdf
+     *         jkaskdnaksdnkasndansdnansdajsdnkasd
+     **/
+
+EOF,
+            ],
+            'pare' => [
+                ['order' => ['param', 'return']],
+                <<<'EOF'
+<?php
+    /**
+     * Hello there
+     *
+     * Long description
+     * goes here.
+     *
+     * @internal
+     * @param string $foo
+     * @param bool   $bar Bar
+     * @param class-string<T> $id
+     * @return bool Return false on failure
+     * @return int  Return the number of changes.
+     * @custom Test!
+     *         asldnaksdkjasdasd
+     * @template T of Extension\Extension
+     * @throws Exception|RuntimeException dfsdf
+     *         jkaskdnaksdnkasndansdnansdajsdnkasd
+     **/
+
+EOF,
+                <<<'EOF'
+<?php
+    /**
+     * Hello there
+     *
+     * Long description
+     * goes here.
+     *
+     * @internal
+     * @return bool Return false on failure
+     * @return int  Return the number of changes.
+     * @custom Test!
+     *         asldnaksdkjasdasd
+     * @template T of Extension\Extension
+     * @param string $foo
+     * @param bool   $bar Bar
+     * @param class-string<T> $id
+     * @throws Exception|RuntimeException dfsdf
+     *         jkaskdnaksdnkasndansdnansdajsdnkasd
+     **/
+
+EOF,
+            ],
+            'pareth' => [
+                ['order' => ['param', 'return', 'throws']],
+                <<<'EOF'
+<?php
+    /**
+     * Hello there
+     *
+     * Long description
+     * goes here.
+     *
+     * @internal
+     * @custom Test!
+     *         asldnaksdkjasdasd
+     * @template T of Extension\Extension
+     * @param string $foo
+     * @param bool   $bar Bar
+     * @param class-string<T> $id
+     * @return bool Return false on failure
+     * @return int  Return the number of changes.
+     * @throws Exception|RuntimeException dfsdf
+     *         jkaskdnaksdnkasndansdnansdajsdnkasd
+     **/
+
+EOF,
+                <<<'EOF'
+<?php
+    /**
+     * Hello there
+     *
+     * Long description
+     * goes here.
+     *
+     * @internal
+     * @return bool Return false on failure
+     * @return int  Return the number of changes.
+     * @custom Test!
+     *         asldnaksdkjasdasd
+     * @template T of Extension\Extension
+     * @param string $foo
+     * @param bool   $bar Bar
+     * @param class-string<T> $id
+     * @throws Exception|RuntimeException dfsdf
+     *         jkaskdnaksdnkasndansdnansdajsdnkasd
+     **/
+
+EOF,
+            ],
+            'pathre' => [
+                ['order' => ['param', 'throws', 'return']],
+                <<<'EOF'
+<?php
+    /**
+     * Hello there
+     *
+     * Long description
+     * goes here.
+     *
+     * @internal
+     * @custom Test!
+     *         asldnaksdkjasdasd
+     * @template T of Extension\Extension
+     * @param string $foo
+     * @param bool   $bar Bar
+     * @param class-string<T> $id
+     * @throws Exception|RuntimeException dfsdf
+     *         jkaskdnaksdnkasndansdnansdajsdnkasd
+     * @return bool Return false on failure
+     * @return int  Return the number of changes.
+     **/
+
+EOF,
+                <<<'EOF'
+<?php
+    /**
+     * Hello there
+     *
+     * Long description
+     * goes here.
+     *
+     * @internal
+     * @return bool Return false on failure
+     * @return int  Return the number of changes.
+     * @custom Test!
+     *         asldnaksdkjasdasd
+     * @template T of Extension\Extension
+     * @param string $foo
+     * @param bool   $bar Bar
+     * @param class-string<T> $id
+     * @throws Exception|RuntimeException dfsdf
+     *         jkaskdnaksdnkasndansdnansdajsdnkasd
+     **/
+
+EOF,
+            ],
+            'tepathre' => [
+                ['order' => ['template', 'param', 'throws', 'return']],
+                <<<'EOF'
+<?php
+    /**
+     * Hello there
+     *
+     * Long description
+     * goes here.
+     *
+     * @internal
+     * @template T of Extension\Extension
+     * @param string $foo
+     * @param bool   $bar Bar
+     * @param class-string<T> $id
+     * @throws Exception|RuntimeException dfsdf
+     *         jkaskdnaksdnkasndansdnansdajsdnkasd
+     * @return bool Return false on failure
+     * @return int  Return the number of changes.
+     * @custom Test!
+     *         asldnaksdkjasdasd
+     **/
+
+EOF,
+                <<<'EOF'
+<?php
+    /**
+     * Hello there
+     *
+     * Long description
+     * goes here.
+     *
+     * @internal
+     * @return bool Return false on failure
+     * @return int  Return the number of changes.
+     * @param string $foo
+     * @param bool   $bar Bar
+     * @param class-string<T> $id
+     * @template T of Extension\Extension
+     * @custom Test!
+     *         asldnaksdkjasdasd
+     * @throws Exception|RuntimeException dfsdf
+     *         jkaskdnaksdnkasndansdnansdajsdnkasd
+     **/
+
+EOF,
+            ],
+            'tepathre2' => [
+                ['order' => ['template', 'param', 'throws', 'return']],
+                <<<'EOF'
+<?php
+    /**
+     * Hello there
+     *
+     * Long description
+     * goes here.
+     *
+     * @internal
+     * @template T of Extension\Extension
+     * @param string $foo
+     * @param bool   $bar Bar
+     * @param class-string<T> $id
+     * @throws Exception|RuntimeException dfsdf
+     *         jkaskdnaksdnkasndansdnansdajsdnkasd
+     * @return bool Return false on failure
+     * @return int  Return the number of changes.
+     * @custom Test!
+     *         asldnaksdkjasdasd
+     **/
+
+EOF,
+                <<<'EOF'
+<?php
+    /**
+     * Hello there
+     *
+     * Long description
+     * goes here.
+     *
+     * @internal
+     * @param string $foo
+     * @param bool   $bar Bar
+     * @param class-string<T> $id
+     * @return bool Return false on failure
+     * @return int  Return the number of changes.
+     * @template T of Extension\Extension
+     * @custom Test!
+     *         asldnaksdkjasdasd
+     * @throws Exception|RuntimeException dfsdf
+     *         jkaskdnaksdnkasndansdnansdajsdnkasd
+     **/
+
+EOF,
+            ],
+        ];
+    }
 }
diff --git a/tests/Fixtures/Integration/set/@Symfony.test-in.php b/tests/Fixtures/Integration/set/@Symfony.test-in.php
index 063908eda..d9448f823 100644
--- a/tests/Fixtures/Integration/set/@Symfony.test-in.php
+++ b/tests/Fixtures/Integration/set/@Symfony.test-in.php
@@ -34,13 +34,12 @@ public function __construct($dummy)
 
     /**
      * Foo
+     *
      * @param string $dummy Some argument description
      * @param array $options
      * @param string|null $data Foo
-     *
-     * @return string|null Transformed input
-     *
      * @throws \RuntimeException
+     * @return string|null Transformed input
      */
     private function transformText($dummy, array $options = array(),$data=null)
     {
@@ -65,7 +64,7 @@ private function transformText($dummy, array $options = array(),$data=null)
             return ucwords($dummy);
         }
 
-        throw new \RuntimeException(sprintf('Unrecognized dummy option "%s"', $dummy));
+        throw new \RuntimeException(sprintf('Unrecognized dummy option "%s".', $dummy));
     }
     private function reverseBoolean($value = null, $theSwitch = false)
     {
diff --git a/tests/Fixtures/Integration/set/@Symfony.test-out.php b/tests/Fixtures/Integration/set/@Symfony.test-out.php
index b17b0bdfd..7c49652bc 100644
--- a/tests/Fixtures/Integration/set/@Symfony.test-out.php
+++ b/tests/Fixtures/Integration/set/@Symfony.test-out.php
@@ -64,7 +64,7 @@ private function transformText($dummy, array $options = [], $data = null)
             return ucwords($dummy);
         }
 
-        throw new \RuntimeException(sprintf('Unrecognized dummy option "%s"', $dummy));
+        throw new \RuntimeException(sprintf('Unrecognized dummy option "%s".', $dummy));
     }
 
     private function reverseBoolean($value = null, $theSwitch = false)
diff --git a/tests/RuleSet/RuleSetTest.php b/tests/RuleSet/RuleSetTest.php
index 58bc69dcf..61aeb33ff 100644
--- a/tests/RuleSet/RuleSetTest.php
+++ b/tests/RuleSet/RuleSetTest.php
@@ -33,6 +33,16 @@
  */
 final class RuleSetTest extends TestCase
 {
+    /**
+     * Options for which order of array elements matters.
+     *
+     * @var string[]
+     */
+    private const ORDER_MATTERS = [
+        'ordered_imports.imports_order',
+        'phpdoc_order.order',
+    ];
+
     /**
      * @param array|bool $ruleConfig
      *
@@ -95,8 +105,8 @@ public function testThatDefaultConfigIsNotPassed(string $setName, string $ruleNa
         }
 
         static::assertNotSame(
-            $this->sortNestedArray($defaultConfig),
-            $this->sortNestedArray($ruleConfig),
+            $this->sortNestedArray($defaultConfig, $ruleName),
+            $this->sortNestedArray($ruleConfig, $ruleName),
             sprintf('Rule "%s" (in RuleSet "%s") has default config passed.', $ruleName, $setName)
         );
     }
@@ -419,23 +429,57 @@ public function testInvalidConfig(): void
         new RuleSet(['@Symfony:risky' => null]);
     }
 
-    private function sortNestedArray(array $array): array
+    private function sortNestedArray(array $array, string $ruleName): array
     {
-        foreach ($array as $key => $element) {
-            if (!\is_array($element)) {
-                continue;
-            }
-            $array[$key] = $this->sortNestedArray($element);
+        $this->doSort($array, $ruleName);
+
+        return $array;
+    }
+
+    /**
+     * Sorts an array of fixer definition recursively.
+     *
+     * Sometimes keys are all string, sometimes they are integers - we need to account for that.
+     *
+     * @param array<int|string,mixed> $data
+     */
+    private function doSort(array &$data, string $path): void
+    {
+        // if order matters do not sort!
+        if (\in_array($path, self::ORDER_MATTERS, true)) {
+            return;
         }
 
-        // sort by key if associative, by values otherwise
-        if (array_keys($array) === range(0, \count($array) - 1)) {
-            sort($array);
+        $keys = array_keys($data);
+
+        if ($this->allInteger($keys)) {
+            sort($data);
         } else {
-            ksort($array);
+            ksort($data);
         }
 
-        return $array;
+        foreach ($data as $key => $value) {
+            if (\is_array($value)) {
+                $this->doSort(
+                    $data[$key],
+                    $path.('' !== $path ? '.' : '').$key
+                );
+            }
+        }
+    }
+
+    /**
+     * @param array<int|string,mixed> $values
+     */
+    private function allInteger(array $values): bool
+    {
+        foreach ($values as $value) {
+            if (!\is_int($value)) {
+                return false;
+            }
+        }
+
+        return true;
     }
 
     /**