Skip to content

Commit f927f97

Browse files
committed
Add EnforceCollationRule for Laravel
1 parent 238339a commit f927f97

9 files changed

Lines changed: 400 additions & 5 deletions

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,22 @@ No configuration is required.
5151

5252
### Laravel
5353

54+
### EnforceCollationRule
55+
56+
Enforces that Laravel Schema::create() and Schema::table() calls specify a table collation (default: utf8).
57+
58+
The rule detects collation being set anywhere inside the Blueprint callback via either:
59+
60+
- `$table->collation('...')`, or
61+
- `$table->collation = '...'`
62+
63+
```yaml
64+
parameters:
65+
phpstanMigrationRules:
66+
phinx:
67+
requiredCollation: utf8mb4
68+
```
69+
5470
### ForbidAfterRule
5571

5672
Forbids using Laravel’s `after()` column modifier in migrations.

extension.neon

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,23 @@ parametersSchema:
55
])
66
])
77

8+
parametersSchema:
9+
phpstanMigrationRules: structure([
10+
laravel: structure([
11+
requiredCollation: string()
12+
])
13+
])
14+
815
parameters:
916
phpstanMigrationRules:
1017
phinx:
1118
requiredCollation: utf8
1219

20+
parameters:
21+
phpstanMigrationRules:
22+
laravel:
23+
requiredCollation: utf8
24+
1325
services:
1426
-
1527
class: PhpStanMigrationRules\Rules\Phinx\EnforceCollationRule
@@ -28,11 +40,18 @@ services:
2840
tags:
2941
- phpstan.rules.rule
3042

43+
-
44+
class: PhpStanMigrationRules\Rules\Laravel\EnforceCollationRule
45+
arguments:
46+
requiredCollation: %phpstanMigrationRules.laravel.requiredCollation%
47+
tags:
48+
- phpstan.rules.rule
49+
3150
-
3251
class: PhpStanMigrationRules\Rules\Laravel\ForbidAfterRule
3352
tags:
3453
- phpstan.rules.rule
35-
54+
3655
-
3756
class: PhpStanMigrationRules\Rules\Laravel\ForbidMultipleTableCreationsRule
3857
tags:
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpStanMigrationRules\Rules\Laravel;
6+
7+
use PhpParser\Node;
8+
use PhpParser\Node\Arg;
9+
use PhpParser\Node\Expr\ArrowFunction;
10+
use PhpParser\Node\Expr\Assign;
11+
use PhpParser\Node\Expr\Closure;
12+
use PhpParser\Node\Expr\MethodCall;
13+
use PhpParser\Node\Expr\PropertyFetch;
14+
use PhpParser\Node\Expr\StaticCall;
15+
use PhpParser\Node\Expr\Variable;
16+
use PhpParser\Node\Identifier;
17+
use PhpParser\Node\Name;
18+
use PhpParser\Node\Scalar\String_;
19+
use PHPStan\Analyser\Scope;
20+
use PHPStan\Rules\Rule;
21+
use PHPStan\Rules\RuleErrorBuilder;
22+
23+
/**
24+
* @implements Rule<StaticCall>
25+
*/
26+
final readonly class EnforceCollationRule implements Rule
27+
{
28+
private const string RULE_IDENTIFIER = 'laravel.schema.requiredCollation';
29+
30+
public function __construct(
31+
private readonly string $requiredCollation
32+
) {
33+
}
34+
35+
public function getNodeType(): string
36+
{
37+
return StaticCall::class;
38+
}
39+
40+
public function processNode(Node $node, Scope $scope): array
41+
{
42+
// Only apply inside Laravel migration classes.
43+
$classReflection = $scope->getClassReflection();
44+
if ($classReflection === null) {
45+
return [];
46+
}
47+
48+
if (!$classReflection->isSubclassOf(\Illuminate\Database\Migrations\Migration::class)) {
49+
return [];
50+
}
51+
52+
$callName = $this->getStaticCallName($node);
53+
if ($callName !== 'create' && $callName !== 'table') {
54+
return [];
55+
}
56+
57+
if (!$node->class instanceof Name) {
58+
return [];
59+
}
60+
61+
$resolved = $scope->resolveName($node->class);
62+
if (
63+
$resolved !== \Illuminate\Support\Facades\Schema::class
64+
&& $resolved !== 'Illuminate\\Database\\Schema\\Schema'
65+
) {
66+
return [];
67+
}
68+
69+
$closure = $this->extractBlueprintCallback($node);
70+
if ($closure === null) {
71+
return [];
72+
}
73+
74+
$tableVarName = $this->extractBlueprintParamName($closure);
75+
if ($tableVarName === null) {
76+
return [];
77+
}
78+
79+
$collation = $this->findCollationInClosure($closure, $tableVarName);
80+
81+
if ($collation !== $this->requiredCollation) {
82+
return [
83+
RuleErrorBuilder::message(sprintf(
84+
'Laravel migrations must set table collation to "%s" in Schema::%s().',
85+
$this->requiredCollation,
86+
$callName
87+
))
88+
->identifier(self::RULE_IDENTIFIER)
89+
->build(),
90+
];
91+
}
92+
93+
return [];
94+
}
95+
96+
private function getStaticCallName(StaticCall $node): ?string
97+
{
98+
return $node->name instanceof Identifier ? $node->name->toString() : null;
99+
}
100+
101+
private function extractBlueprintCallback(StaticCall $node): ?Closure
102+
{
103+
if (!isset($node->args[1]) || !$node->args[1] instanceof Arg) {
104+
return null;
105+
}
106+
107+
$value = $node->args[1]->value;
108+
return $value instanceof Closure ? $value : null;
109+
}
110+
111+
private function extractBlueprintParamName(Closure $closure): ?string
112+
{
113+
if (!isset($closure->params[0])) {
114+
return null;
115+
}
116+
117+
$var = $closure->params[0]->var;
118+
if (!$var instanceof Variable) {
119+
return null;
120+
}
121+
122+
return is_string($var->name) ? $var->name : null;
123+
}
124+
125+
private function findCollationInClosure(Closure $closure, string $tableVarName): ?string
126+
{
127+
$stmts = $closure->stmts;
128+
129+
foreach ($stmts as $stmt) {
130+
$found = $this->scanNodeForCollation($stmt, $tableVarName);
131+
if ($found !== null) {
132+
return $found;
133+
}
134+
}
135+
136+
return null;
137+
}
138+
139+
private function scanNodeForCollation(Node $node, string $tableVarName): ?string
140+
{
141+
// Do not walk into nested functions; they can capture $table and create false positives.
142+
if ($node instanceof Closure || $node instanceof ArrowFunction) {
143+
return null;
144+
}
145+
146+
// $table->collation('utf8mb4')
147+
if (
148+
$node instanceof MethodCall
149+
&& $node->name instanceof Identifier
150+
&& $node->name->toString() === 'collation'
151+
&& $this->isTableVar($node->var, $tableVarName)
152+
&& isset($node->args[0])
153+
&& $node->args[0] instanceof Arg
154+
&& $node->args[0]->value instanceof String_
155+
) {
156+
return $node->args[0]->value->value;
157+
}
158+
159+
// $table->collation = 'utf8mb4'
160+
if (
161+
$node instanceof Assign
162+
&& $node->var instanceof PropertyFetch
163+
&& $node->var->name instanceof Identifier
164+
&& $node->var->name->toString() === 'collation'
165+
&& $this->isTableVar($node->var->var, $tableVarName)
166+
&& $node->expr instanceof String_
167+
) {
168+
return $node->expr->value;
169+
}
170+
171+
// Recurse into child nodes
172+
foreach ($node->getSubNodeNames() as $subNodeName) {
173+
$subNode = $node->$subNodeName;
174+
175+
if ($subNode instanceof Node) {
176+
$found = $this->scanNodeForCollation($subNode, $tableVarName);
177+
if ($found !== null) {
178+
return $found;
179+
}
180+
continue;
181+
}
182+
183+
if (is_array($subNode)) {
184+
foreach ($subNode as $item) {
185+
if ($item instanceof Node) {
186+
$found = $this->scanNodeForCollation($item, $tableVarName);
187+
if ($found !== null) {
188+
return $found;
189+
}
190+
}
191+
}
192+
}
193+
}
194+
195+
return null;
196+
}
197+
198+
private function isTableVar(Node $node, string $tableVarName): bool
199+
{
200+
if (!$node instanceof Variable) {
201+
return false;
202+
}
203+
204+
return is_string($node->name) && $node->name === $tableVarName;
205+
}
206+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpStanMigrationRules\Tests\Rules\Laravel;
6+
7+
use PhpStanMigrationRules\Rules\Laravel\EnforceCollationRule;
8+
use PHPStan\Rules\Rule;
9+
use PHPStan\Testing\RuleTestCase;
10+
11+
/**
12+
* @extends RuleTestCase<EnforceCollationRule>
13+
*/
14+
final class EnforceCollationRuleTest extends RuleTestCase
15+
{
16+
protected function getRule(): Rule
17+
{
18+
return new EnforceCollationRule('utf8mb4');
19+
}
20+
21+
public function testReportsMissingCollation(): void
22+
{
23+
$this->analyse(
24+
[__DIR__ . '/fixtures/MissingCollation.php'],
25+
[
26+
[
27+
'Laravel migrations must set table collation to "utf8mb4" in Schema::create().',
28+
15,
29+
],
30+
]
31+
);
32+
}
33+
34+
public function testReportsWrongCollation(): void
35+
{
36+
$this->analyse(
37+
[__DIR__ . '/fixtures/WrongCollation.php'],
38+
[
39+
[
40+
'Laravel migrations must set table collation to "utf8mb4" in Schema::create().',
41+
15,
42+
],
43+
]
44+
);
45+
}
46+
47+
public function testAllowsCorrectCollationTopLevel(): void
48+
{
49+
$this->analyse(
50+
[__DIR__ . '/fixtures/AllowCollation.php'],
51+
[]
52+
);
53+
}
54+
55+
public function testAllowsCorrectCollationViaPropertyAssignment(): void
56+
{
57+
$this->analyse(
58+
[__DIR__ . '/fixtures/AllowCollationPropertyAssignment.php'],
59+
[]
60+
);
61+
}
62+
63+
public function testDoesNotReportOutsideLaravelMigration(): void
64+
{
65+
$this->analyse(
66+
[__DIR__ . '/fixtures/NonMigrationClass.php'],
67+
[
68+
[
69+
'No error to ignore is reported on line 15.',
70+
15,
71+
]
72+
],
73+
);
74+
}
75+
}

tests/Rules/Laravel/ForbidMultipleTableCreationsRule.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ protected function getRule(): Rule
2121
public function testReportsSecondSchemaCreateInMigration(): void
2222
{
2323
$this->analyse(
24-
[__DIR__ . '/Fixtures/ForbidMultipleTableCreations.php'],
24+
[__DIR__ . '/fixtures/ForbidMultipleTableCreations.php'],
2525
[
2626
[
2727
'Creating multiple tables in a single Laravel migration is forbidden. Each migration should create exactly one table.',
@@ -35,7 +35,7 @@ public function testReportsSecondSchemaCreateInMigration(): void
3535
public function testAllowsSingleSchemaCreateInMigration(): void
3636
{
3737
$this->analyse(
38-
[__DIR__ . '/Fixtures/AllowSingleTableCreation.php'],
38+
[__DIR__ . '/fixtures/AllowSingleTableCreation.php'],
3939
[]
4040
);
4141
}
@@ -46,8 +46,8 @@ public function testDoesNotReportOutsideLaravelMigration(): void
4646
[__DIR__ . '/fixtures/NonMigrationClass.php'],
4747
[
4848
[
49-
'No error to ignore is reported on line 14.',
50-
14,
49+
'No error to ignore is reported on line 15.',
50+
15,
5151
]
5252
],
5353
);
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpStanMigrationRules\Tests\Rules\Laravel\Fixtures;
6+
7+
use Illuminate\Database\Migrations\Migration;
8+
use Illuminate\Database\Schema\Blueprint;
9+
use Illuminate\Support\Facades\Schema;
10+
11+
final class AllowCollation extends Migration
12+
{
13+
public function up(): void
14+
{
15+
Schema::create('users', function (Blueprint $table): void {
16+
$table->collation('utf8mb4');
17+
$table->string('email');
18+
});
19+
}
20+
}

0 commit comments

Comments
 (0)