diff --git a/README.md b/README.md
index 2f75f9353..198b59d94 100644
--- a/README.md
+++ b/README.md
@@ -54,6 +54,7 @@ Slevomat Coding Standard for [PHP_CodeSniffer](https://github.com/PHPCSStandards
- [SlevomatCodingStandard.Classes.DisallowStringExpressionPropertyFetch](doc/classes.md#slevomatcodingstandardclassesdisallowstringexpressionpropertyfetch-) 🔧
- [SlevomatCodingStandard.Classes.EmptyLinesAroundClassBraces](doc/classes.md#slevomatcodingstandardclassesemptylinesaroundclassbraces-) 🔧
- [SlevomatCodingStandard.Classes.EnumCaseSpacing](doc/classes.md#slevomatcodingstandardclassesenumcasespacing-) 🔧
+ - [SlevomatCodingStandard.Classes.ClassKeywordOrder](doc/classes.md#slevomatcodingstandardclassesclasskeywordorder-) 🔧
- [SlevomatCodingStandard.Classes.ForbiddenPublicProperty](doc/classes.md#slevomatcodingstandardclassesforbiddenpublicproperty)
- [SlevomatCodingStandard.Classes.MethodSpacing](doc/classes.md#slevomatcodingstandardclassesmethodspacing-) 🔧
- [SlevomatCodingStandard.Classes.ModernClassNameReference](doc/classes.md#slevomatcodingstandardclassesmodernclassnamereference-) 🔧
diff --git a/SlevomatCodingStandard/Sniffs/Classes/ClassKeywordOrderSniff.php b/SlevomatCodingStandard/Sniffs/Classes/ClassKeywordOrderSniff.php
new file mode 100644
index 000000000..544ffc1a5
--- /dev/null
+++ b/SlevomatCodingStandard/Sniffs/Classes/ClassKeywordOrderSniff.php
@@ -0,0 +1,112 @@
+
+ */
+ public function register(): array
+ {
+ return [
+ T_CLASS,
+ ];
+ }
+
+ public function process(File $phpcsFile, int $stackPtr): void
+ {
+ $tokens = $phpcsFile->getTokens();
+
+ $modifierTokens = [
+ T_ABSTRACT => 'abstract',
+ T_FINAL => 'final',
+ T_READONLY => 'readonly',
+ ];
+
+ $foundModifiers = [];
+ $currentIndex = TokenHelper::findPreviousEffective($phpcsFile, $stackPtr - 1);
+
+ while ($currentIndex !== null && isset($modifierTokens[$tokens[$currentIndex]['code']])) {
+ $foundModifiers[$currentIndex] = $tokens[$currentIndex]['code'];
+ $currentIndex = TokenHelper::findPreviousEffective($phpcsFile, $currentIndex - 1);
+ }
+
+ if (count($foundModifiers) === 0) {
+ return;
+ }
+
+ ksort($foundModifiers);
+
+ $actualOrderCodes = array_values($foundModifiers);
+ $actualOrderText = array_map(static fn ($code) => $modifierTokens[$code], $actualOrderCodes);
+
+ $sortedModifiers = $foundModifiers;
+ uasort($sortedModifiers, static function ($a, $b) {
+ $priority = [
+ T_ABSTRACT => 0,
+ T_FINAL => 0,
+ T_READONLY => 1,
+ ];
+ return $priority[$a] <=> $priority[$b];
+ });
+
+ $expectedOrderCodes = array_values($sortedModifiers);
+ $expectedOrderText = array_map(static fn ($code) => $modifierTokens[$code], $expectedOrderCodes);
+
+ if ($actualOrderCodes === $expectedOrderCodes) {
+ return;
+ }
+
+ $error = 'Class keywords are not in the correct order. Found: "%s class"; Expected: "%s class"';
+ $data = [
+ implode(' ', $actualOrderText),
+ implode(' ', $expectedOrderText),
+ ];
+
+ $fix = $phpcsFile->addFixableError($error, $stackPtr, self::CODE_WRONG_CLASS_KEYWORD_ORDER, $data);
+
+ if ($fix !== true) {
+ return;
+ }
+
+ $phpcsFile->fixer->beginChangeset();
+
+ foreach (array_keys($foundModifiers) as $ptr) {
+ $phpcsFile->fixer->replaceToken($ptr, '');
+
+ if ($tokens[$ptr + 1]['code'] === T_WHITESPACE) {
+ $phpcsFile->fixer->replaceToken($ptr + 1, '');
+ }
+ }
+
+ $firstModifierPtr = array_key_first($foundModifiers);
+
+ $newContent = implode(' ', $expectedOrderText) . ' ';
+
+ $phpcsFile->fixer->addContentBefore($firstModifierPtr, $newContent);
+
+ $phpcsFile->fixer->endChangeset();
+ }
+
+}
diff --git a/build/phpcs.xml b/build/phpcs.xml
index f7243821a..d6f415240 100644
--- a/build/phpcs.xml
+++ b/build/phpcs.xml
@@ -222,6 +222,7 @@
+
diff --git a/doc/classes.md b/doc/classes.md
index a5688ccf4..eaa09abad 100644
--- a/doc/classes.md
+++ b/doc/classes.md
@@ -161,6 +161,12 @@ Sniff provides the following settings:
* `minLinesCountBeforeWithoutComment`: minimum number of lines before enum case without a documentation comment or attribute
* `maxLinesCountBeforeWithoutComment`: maximum number of lines before enum case without a documentation comment or attribute
+#### SlevomatCodingStandard.Classes.ClassKeywordOrder 🔧
+
+Enforces the correct order of class modifiers (e.g., `final`, `abstract`, `readonly`).
+
+Required order is (final | abstract) readonly class. That is, use either `final` or `abstract` (never both), then `readonly` if present, then `class`.
+
#### SlevomatCodingStandard.Classes.ForbiddenPublicProperty
Disallows using public properties.
diff --git a/tests/Sniffs/Classes/ClassKeywordOrderSniffTest.php b/tests/Sniffs/Classes/ClassKeywordOrderSniffTest.php
new file mode 100644
index 000000000..19e8c095c
--- /dev/null
+++ b/tests/Sniffs/Classes/ClassKeywordOrderSniffTest.php
@@ -0,0 +1,39 @@
+getErrorCount());
+
+ self::assertSniffError(
+ $report,
+ 30,
+ ClassKeywordOrderSniff::CODE_WRONG_CLASS_KEYWORD_ORDER,
+ 'Class keywords are not in the correct order. Found: "readonly final class"; Expected: "final readonly class"',
+ );
+
+ self::assertSniffError(
+ $report,
+ 49,
+ ClassKeywordOrderSniff::CODE_WRONG_CLASS_KEYWORD_ORDER,
+ 'Class keywords are not in the correct order. Found: "readonly abstract class"; Expected: "abstract readonly class"',
+ );
+
+ self::assertAllFixedInFile($report);
+ }
+
+}
diff --git a/tests/Sniffs/Classes/data/classKeywordOrderErrors.fixed.php b/tests/Sniffs/Classes/data/classKeywordOrderErrors.fixed.php
new file mode 100644
index 000000000..163ac1750
--- /dev/null
+++ b/tests/Sniffs/Classes/data/classKeywordOrderErrors.fixed.php
@@ -0,0 +1,56 @@
+= 8.2
+
+class Foo1
+{
+
+ public function bar()
+ {
+
+ }
+}
+
+final class Foo2
+{
+
+ public function bar()
+ {
+
+ }
+}
+
+readonly class Foo3
+{
+
+ public function bar()
+ {
+
+ }
+}
+
+final readonly class Foo4
+{
+
+ public function bar()
+ {
+
+ }
+}
+
+
+abstract class Foo5
+{
+
+ public function bar()
+ {
+
+ }
+}
+
+abstract readonly class Foo6
+{
+
+ public function bar()
+ {
+
+ }
+}
diff --git a/tests/Sniffs/Classes/data/classKeywordOrderErrors.php b/tests/Sniffs/Classes/data/classKeywordOrderErrors.php
new file mode 100644
index 000000000..b2cc0a46d
--- /dev/null
+++ b/tests/Sniffs/Classes/data/classKeywordOrderErrors.php
@@ -0,0 +1,56 @@
+= 8.2
+
+class Foo1
+{
+
+ public function bar()
+ {
+
+ }
+}
+
+final class Foo2
+{
+
+ public function bar()
+ {
+
+ }
+}
+
+readonly class Foo3
+{
+
+ public function bar()
+ {
+
+ }
+}
+
+readonly final class Foo4
+{
+
+ public function bar()
+ {
+
+ }
+}
+
+
+abstract class Foo5
+{
+
+ public function bar()
+ {
+
+ }
+}
+
+readonly abstract class Foo6
+{
+
+ public function bar()
+ {
+
+ }
+}
diff --git a/tests/Sniffs/Classes/data/classKeywordOrderNoErrors.php b/tests/Sniffs/Classes/data/classKeywordOrderNoErrors.php
new file mode 100644
index 000000000..163ac1750
--- /dev/null
+++ b/tests/Sniffs/Classes/data/classKeywordOrderNoErrors.php
@@ -0,0 +1,56 @@
+= 8.2
+
+class Foo1
+{
+
+ public function bar()
+ {
+
+ }
+}
+
+final class Foo2
+{
+
+ public function bar()
+ {
+
+ }
+}
+
+readonly class Foo3
+{
+
+ public function bar()
+ {
+
+ }
+}
+
+final readonly class Foo4
+{
+
+ public function bar()
+ {
+
+ }
+}
+
+
+abstract class Foo5
+{
+
+ public function bar()
+ {
+
+ }
+}
+
+abstract readonly class Foo6
+{
+
+ public function bar()
+ {
+
+ }
+}