Repository: tightenco/tlint (php)

Issue:
ModelMethodOrder and ClassThingsOrder conflicting
The `ModelMethodOrder` linter dictates that the "boot" method should come last, but since the boot method is static, `ClassThingsOrder` dictates that it should go first.

When I put my "boot" method at the top of the file, I get the following message: 
```
! Model method order should be: relationships > scopes > accessors > mutators > booting > boot > booted > custom
```

When I put it at the bottom:
```
! Class "things" should be ordered trait use, public static property, protected static property, private static property, public constant, protected constant, private constant, public property, protected property, private property, constructor, public static method, protected static method, private static method, public method, protected method, private method, magic method
```

This has been happening since version 5.0.2.

Pull request:
make ModelMethodOrder and ClassThingsOrder linters compatible
fixes #165 

Patch:
diff --git a/src/Concerns/IdentifiesModelMethodTypes.php b/src/Concerns/IdentifiesModelMethodTypes.php
index ef789e3..d6b684d 100644
--- a/src/Concerns/IdentifiesModelMethodTypes.php
+++ b/src/Concerns/IdentifiesModelMethodTypes.php
@@ -58,6 +58,11 @@ trait IdentifiesModelMethodTypes
             && $stmt->name == 'booted';
     }
 
+    private function isCustomStaticMethod(ClassMethod $stmt)
+    {
+        return $stmt->isStatic();
+    }
+
     private function isCustomMethod(ClassMethod $stmt)
     {
         if ($stmt->isAbstract()) {
diff --git a/src/Linters/ModelMethodOrder.php b/src/Linters/ModelMethodOrder.php
index eb7295b..fbcd50d 100644
--- a/src/Linters/ModelMethodOrder.php
+++ b/src/Linters/ModelMethodOrder.php
@@ -17,17 +17,18 @@ class ModelMethodOrder extends BaseLinter
     use IdentifiesModelMethodTypes;
     use IdentifiesExtends;
 
-    public const description = 'Model method order should be: relationships > scopes > accessors > mutators > booting > boot > booted > custom';
+    public const description = 'Model method order should be: booting > boot > booted > custom_static > relationships > scopes > accessors > mutators > custom';
 
     protected const METHOD_ORDER = [
-        0 => 'relationship',
-        1 => 'scope',
-        2 => 'accessor',
-        3 => 'mutator',
-        4 => 'booting',
-        5 => 'boot',
-        6 => 'booted',
-        7 => 'custom',
+        0 => 'booting',
+        1 => 'boot',
+        2 => 'booted',
+        3 => 'custom_static',
+        4 => 'relationship',
+        5 => 'scope',
+        6 => 'accessor',
+        7 => 'mutator',
+        8 => 'custom',
     ];
 
     protected $tests;
@@ -38,13 +39,15 @@ class ModelMethodOrder extends BaseLinter
 
         // order of tests is important
         $this->tests = [
-            // first detect the static boot methods
+            // detect the static boot methods
             'booting' => Closure::fromCallable([$this, 'isBootingMethod']),
             'boot' => Closure::fromCallable([$this, 'isBootMethod']),
             'booted' => Closure::fromCallable([$this, 'isBootedMethod']),
-            // second declare everything custom that's not public
+            // declare everything else custom static
+            'custom_static' => Closure::fromCallable([$this, 'isCustomStaticMethod']),
+            // declare everything custom that's not public
             'custom' => Closure::fromCallable([$this, 'isCustomMethod']),
-            // third detect all methods that have to be public
+            // detect all methods that have to be public
             'relationship' => Closure::fromCallable([$this, 'isRelationshipMethod']),
             'scope' => Closure::fromCallable([$this, 'isScopeMethod']),
             'accessor' => Closure::fromCallable([$this, 'isAccessorMethod']),
@@ -82,12 +85,16 @@ class ModelMethodOrder extends BaseLinter
                 }, $methods);
 
                 $methodTypesShouldBeOrderedLike = $methodTypes;
-                uasort($methodTypesShouldBeOrderedLike, function ($typeA, $typeB) {
+                // sort all methods by type and in type blocks alphabetically
+                uksort($methodTypesShouldBeOrderedLike, function ($methodA, $methodB) use ($methodTypes) {
+                    $typeA = $methodTypes[$methodA];
+                    $typeB = $methodTypes[$methodB];
+
                     $sortA = array_flip(self::METHOD_ORDER)[$typeA];
                     $sortB = array_flip(self::METHOD_ORDER)[$typeB];
 
                     if ($sortA == $sortB) {
-                        return 0;
+                        return strnatcasecmp($methodA, $methodB);
                     }
 
                     return ($sortA < $sortB) ? -1 : 1;


Test patch:
diff --git a/tests/Linting/Composite/ModelMethodOrderAndClassThingsOrderTest.php b/tests/Linting/Composite/ModelMethodOrderAndClassThingsOrderTest.php
new file mode 100644
index 0000000..135be7d
--- /dev/null
+++ b/tests/Linting/Composite/ModelMethodOrderAndClassThingsOrderTest.php
@@ -0,0 +1,146 @@
+<?php
+
+namespace tests\Linting\Composite;
+
+use PHPUnit\Framework\TestCase;
+use Tighten\Linters\ClassThingsOrder;
+use Tighten\Linters\ModelMethodOrder;
+use Tighten\TLint;
+
+class ModelMethodOrderAndClassThingsOrderTest extends TestCase
+{
+    /**
+     * @test
+     * @dataProvider models
+     */
+    public function a_valid_sorted_model_passes_both_linters(string $file): void
+    {
+        $lints = [];
+        $lints += (new TLint)->lint(
+            new ModelMethodOrder($file),
+        );
+        $lints += (new TLint)->lint(
+            new ClassThingsOrder($file),
+        );
+
+        $this->assertEmpty($lints);
+    }
+
+    public function models(): array
+    {
+        return [
+            [
+                <<<PHP
+<?php
+
+namespace App;
+
+class Post extends Model
+{
+    use Notifiable;
+
+    protected \$fillable = [];
+    protected \$guarded = [];
+    protected \$hidden = [];
+    protected \$casts = [];
+    protected \$dates = [];
+
+    public static function booting(){}
+
+    public static function boot(){}
+
+    public static function booted(){}
+
+    public static function fromDraft(){}
+
+    protected static function toDraft(){}
+
+    public function author(): BelongsTo {}
+
+    public function scopeWhereIsPublished(){}
+
+    public function getPasswordAttribute(){}
+
+    public function setPasswordAttribute(){}
+
+    public function publish(){}
+
+    public function unpublish(){}
+
+    protected function drafting(){}
+}
+PHP
+            ],
+            [
+                <<<PHP
+<?php
+
+namespace App\Models;
+
+class Song extends Model implements HasMedia
+{
+    use Actionable;
+    use HasTranslatableSections;
+    use InteractsWithMedia;
+    use Searchable;
+    use Sluggable;
+
+    protected \$appends = ['slug', 'title_with_translation'];
+
+    protected \$guarded = [];
+
+    protected \$hidden = ['internal_note'];
+
+    protected static function boot()
+    {
+    }
+
+    public function culture(): BelongsTo
+    {
+    }
+
+    public function tenants(): BelongsToMany
+    {
+    }
+
+    public function sections(): HasMany
+    {
+    }
+
+    public function scopeNotOnCurrentTenant(Builder \$query): void
+    {
+    }
+
+    public function scopePublished(Builder \$query): void
+    {
+    }
+
+    public function getTitleWithTranslationAttribute()
+    {
+    }
+
+    public function loadOrderedTranslatedSections(): self
+    {
+    }
+
+    public function url(): string
+    {
+    }
+
+    public function registerMediaCollections(): void
+    {
+    }
+
+    public function toSearchableArray(): array
+    {
+    }
+
+    public function shouldBeSearchable(): bool
+    {
+    }
+}
+PHP
+            ],
+        ];
+    }
+}
diff --git a/tests/Linting/Linters/ModelMethodOrderTest.php b/tests/Linting/Linters/ModelMethodOrderTest.php
index 493e830..87cd4ca 100644
--- a/tests/Linting/Linters/ModelMethodOrderTest.php
+++ b/tests/Linting/Linters/ModelMethodOrderTest.php
@@ -39,6 +39,21 @@ class Thing extends Model
 
     }
 
+    public static function booting()
+    {
+
+    }
+
+    public static function make(array \$attributes)
+    {
+
+    }
+
+    protected static function makeInternal(array \$attributes)
+    {
+
+    }
+
     public function scopeWhereInactive(Builder \$query)
     {
         return \$query->where('is_active', false);
@@ -64,6 +79,11 @@ class Thing extends Model
         \$this->attributes['first_name'] = strtolower(\$value);
     }
 
+    protected function publish()
+    {
+
+    }
+
     public function activate()
     {
         \$this->is_active = true;
@@ -109,23 +129,26 @@ PHP;
         $this->assertInstanceOf(ModelMethodOrder::class, $lints[0]->getLinter());
 
         $this->assertEquals(implode(PHP_EOL, [
-            'Model method order should be: relationships > scopes > accessors > mutators > booting > boot > booted > custom',
+            'Model method order should be: booting > boot > booted > custom_static > relationships > scopes > accessors > mutators > custom',
             'Methods are expected to be ordered like:',
+            ' * booting() is matched as "booting"',
+            ' * boot() is matched as "boot"',
+            ' * booted() is matched as "booted"',
+            ' * make() is matched as "custom_static"',
+            ' * makeInternal() is matched as "custom_static"',
             ' * category() is matched as "relationship"',
-            ' * phone() is matched as "relationship"',
+            ' * comments() is matched as "relationship"',
             ' * images() is matched as "relationship"',
             ' * media() is matched as "relationship"',
-            ' * comments() is matched as "relationship"',
+            ' * phone() is matched as "relationship"',
             ' * tags() is matched as "relationship"',
-            ' * scopeWhereInactive() is matched as "scope"',
             ' * scopeWhereActive() is matched as "scope"',
+            ' * scopeWhereInactive() is matched as "scope"',
             ' * getFirstNameAttribute() is matched as "accessor"',
             ' * setFirstNameAttribute() is matched as "mutator"',
             ' * setLastNameAttribute() is matched as "mutator"',
-            ' * booting() is matched as "booting"',
-            ' * boot() is matched as "boot"',
-            ' * booted() is matched as "booted"',
             ' * activate() is matched as "custom"',
+            ' * publish() is matched as "custom"',
         ]), $lints[0]->getLinter()->getLintDescription());
 
         $this->assertEquals(11, $lints[0]->getNode()->getLine());