From f417ec23c2eada4eaddf3ec9e4cff7ccc71a3572 Mon Sep 17 00:00:00 2001 From: Jean-Marc Strauven Date: Tue, 20 Jan 2026 20:58:32 +0100 Subject: [PATCH] feat: integrate PHPStan, Rector and Pint code quality tools (#13) * feat: integrate PHPStan, Rector and Pint code quality tools - Add larastan, rector, and rector-laravel dev dependencies - Configure PHPStan at max level with Larastan extension - Configure Rector with Laravel sets and code quality rules - Configure Pint with strict rules (final_class, strict_types) - Add composer scripts: lint, test:lint, test:types, test:unit - Add GitHub Actions workflow for CI on push/PR - Apply code style fixes across all files * docs: add code quality section to README --- .github/workflows/tests.yml | 55 ++++ README.md | 58 +++- app/Http/Controllers/Api/ApiController.php | 2 + .../Controllers/Api/V1/AuthController.php | 12 +- app/Http/Controllers/Controller.php | 2 + app/Http/Requests/Api/V1/LoginRequest.php | 11 +- app/Http/Requests/Api/V1/RegisterRequest.php | 12 +- app/Http/Resources/UserResource.php | 7 +- app/Models/User.php | 23 +- app/Providers/AppServiceProvider.php | 22 +- app/Traits/ApiResponse.php | 10 +- bootstrap/app.php | 2 + bootstrap/providers.php | 2 + composer.json | 23 +- composer.lock | 282 +++++++++++++++++- config/app.php | 2 + config/auth.php | 6 +- config/cache.php | 2 + config/cors.php | 2 + config/database.php | 6 +- config/filesystems.php | 2 + config/logging.php | 2 + config/mail.php | 2 + config/queue.php | 2 + config/sanctum.php | 13 +- config/scramble.php | 2 + config/services.php | 2 + config/session.php | 2 + database/factories/UserFactory.php | 13 +- .../0001_01_01_000000_create_users_table.php | 8 +- .../0001_01_01_000001_create_cache_table.php | 6 +- .../0001_01_01_000002_create_jobs_table.php | 8 +- ...01_create_personal_access_tokens_table.php | 4 +- database/seeders/DatabaseSeeder.php | 4 +- phpstan.neon | 21 ++ pint.json | 63 ++++ public/index.php | 2 + rector.php | 54 ++++ routes/api.php | 2 + routes/api/v1.php | 6 +- routes/console.php | 4 +- routes/web.php | 2 + tests/Feature/Api/V1/AuthTest.php | 34 ++- tests/Pest.php | 12 +- tests/Unit/ExampleTest.php | 4 +- 45 files changed, 732 insertions(+), 83 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 phpstan.neon create mode 100644 pint.json create mode 100644 rector.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..5b490b4 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,55 @@ +name: tests + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + ci: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.3 + tools: composer:v2 + coverage: xdebug + + - name: Install Dependencies + run: composer install --no-interaction --prefer-dist --optimize-autoloader + + - name: Copy Environment File + run: cp .env.example .env + + - name: Generate Application Key + run: php artisan key:generate + + - name: Create Database + run: touch database/database.sqlite + + - name: Rector Cache + uses: actions/cache@v4 + with: + path: /tmp/rector + key: ${{ runner.os }}-rector-${{ hashFiles('composer.lock') }} + restore-keys: ${{ runner.os }}-rector- + - run: mkdir -p /tmp/rector + + - name: PHPStan Cache + uses: actions/cache@v4 + with: + path: /tmp/phpstan + key: ${{ runner.os }}-phpstan-${{ hashFiles('composer.lock') }} + restore-keys: ${{ runner.os }}-phpstan- + - run: mkdir -p /tmp/phpstan + + - name: Tests + run: composer test diff --git a/README.md b/README.md index 683793a..4a5427d 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ A production-ready, API-only Laravel 12 starter kit following the 2024-2025 REST - **Data Objects** - Type-safe DTOs via [spatie/laravel-data](https://github.com/spatie/laravel-data) - **Auto Documentation** - Zero-annotation OpenAPI 3.1 via [dedoc/scramble](https://github.com/dedoc/scramble) - **Modern Testing** - Pest PHP with Laravel HTTP testing +- **Code Quality** - PHPStan (max level), Rector, and Pint with strict rules - **Rate Limiting** - Configurable per-route rate limiters - **Standardized Responses** - Consistent JSON response format @@ -435,15 +436,60 @@ it('requires authentication', function () { }); ``` +## Code Quality + +This kit includes strict code quality tools configured following [nunomaduro/laravel-starter-kit](https://github.com/nunomaduro/laravel-starter-kit) standards. + +### Tools + +| Tool | Purpose | Config | +|------|---------|--------| +| [PHPStan](https://phpstan.org/) + [Larastan](https://github.com/larastan/larastan) | Static analysis (level max) | `phpstan.neon` | +| [Rector](https://getrector.com/) | Automated refactoring | `rector.php` | +| [Pint](https://laravel.com/docs/pint) | Code style (strict rules) | `pint.json` | + +### Composer Scripts + +```bash +# Apply all fixes (Rector + Pint) +composer lint + +# Check without fixing (CI mode) +composer test:lint + +# Static analysis only +composer test:types + +# Unit tests only +composer test:unit + +# Full test suite (lint + types + unit) +composer test +``` + +### With Docker + +```bash +docker compose exec app composer lint +docker compose exec app composer test +``` + +### Strict Rules Applied + +- `declare(strict_types=1)` on all files +- `final` classes by default +- Type declarations enforced +- Dead code removal +- Early returns +- Strict comparisons + +### GitHub Actions + +Tests run automatically on push/PR to `main` via `.github/workflows/tests.yml`. + ## Development Commands ```bash -# Code formatting (Laravel Pint) -docker compose run --rm app ./vendor/bin/pint - -# Check code style without fixing -docker compose run --rm app ./vendor/bin/pint --test - # List all routes docker compose run --rm app php artisan route:list diff --git a/app/Http/Controllers/Api/ApiController.php b/app/Http/Controllers/Api/ApiController.php index fca9c3e..6993a82 100644 --- a/app/Http/Controllers/Api/ApiController.php +++ b/app/Http/Controllers/Api/ApiController.php @@ -1,5 +1,7 @@ create([ 'name' => $request->name, 'email' => $request->email, 'password' => Hash::make($request->password), @@ -31,7 +33,7 @@ class AuthController extends ApiController public function login(LoginRequest $request): JsonResponse { - $user = User::where('email', $request->email)->first(); + $user = User::query()->where('email', $request->email)->first(); if (! $user || ! Hash::check($request->password, $user->password)) { return $this->unauthorized('Invalid credentials'); @@ -47,7 +49,9 @@ class AuthController extends ApiController public function logout(Request $request): JsonResponse { - $request->user()->currentAccessToken()->delete(); + /** @var User $user */ + $user = $request->user(); + $user->currentAccessToken()->delete(); return $this->success(message: 'Logged out successfully'); } diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 8677cd5..e2af3d2 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -1,5 +1,7 @@ |string> + * @return array|string> */ public function rules(): array { diff --git a/app/Http/Requests/Api/V1/RegisterRequest.php b/app/Http/Requests/Api/V1/RegisterRequest.php index e9cfbdf..f7b6bd6 100644 --- a/app/Http/Requests/Api/V1/RegisterRequest.php +++ b/app/Http/Requests/Api/V1/RegisterRequest.php @@ -1,10 +1,18 @@ |string> + * @return array|string> */ public function rules(): array { diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index e870af9..dcf8bd1 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -1,14 +1,17 @@ diff --git a/app/Models/User.php b/app/Models/User.php index 91135d7..bb4a434 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -1,17 +1,34 @@ */ - use HasApiTokens, HasFactory, Notifiable; + use HasApiTokens; + + /** @use HasFactory */ + use HasFactory; + + use Notifiable; /** * The attributes that are mass assignable. diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 47320a3..b0c9ae5 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -1,5 +1,7 @@ by($request->user()?->id ?: $request->ip()); - }); + RateLimiter::for('api', fn (Request $request) => Limit::perMinute(60)->by($request->user()?->id ?: $request->ip())); // Auth endpoints - more restrictive (prevent brute force) - RateLimiter::for('auth', function (Request $request) { - return Limit::perMinute(5)->by($request->ip()); - }); + RateLimiter::for('auth', fn (Request $request) => Limit::perMinute(5)->by($request->ip())); // Authenticated user requests - higher limit - RateLimiter::for('authenticated', function (Request $request) { - return $request->user() - ? Limit::perMinute(120)->by($request->user()->id) - : Limit::perMinute(60)->by($request->ip()); - }); + RateLimiter::for('authenticated', fn (Request $request) => $request->user() + ? Limit::perMinute(120)->by($request->user()->id) + : Limit::perMinute(60)->by($request->ip())); } } diff --git a/app/Traits/ApiResponse.php b/app/Traits/ApiResponse.php index bc1da2c..22a5f7b 100644 --- a/app/Traits/ApiResponse.php +++ b/app/Traits/ApiResponse.php @@ -1,5 +1,7 @@ json(null, Response::HTTP_NO_CONTENT); } + /** + * @param array $errors + */ protected function error( string $message = 'Error', int $code = Response::HTTP_BAD_REQUEST, @@ -41,7 +46,7 @@ trait ApiResponse 'message' => $message, ]; - if (! empty($errors)) { + if ($errors !== []) { $response['errors'] = $errors; } @@ -63,6 +68,9 @@ trait ApiResponse return $this->error($message, Response::HTTP_FORBIDDEN); } + /** + * @param array $errors + */ protected function validationError(array $errors, string $message = 'Validation failed'): JsonResponse { return $this->error($message, Response::HTTP_UNPROCESSABLE_ENTITY, $errors); diff --git a/bootstrap/app.php b/bootstrap/app.php index c3928c5..72ab5bf 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,7 @@ [ 'users' => [ 'driver' => 'eloquent', - 'model' => env('AUTH_MODEL', App\Models\User::class), + 'model' => env('AUTH_MODEL', User::class), ], // 'users' => [ diff --git a/config/cache.php b/config/cache.php index b32aead..e5a9aa8 100644 --- a/config/cache.php +++ b/config/cache.php @@ -1,5 +1,7 @@ true, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ - (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), + (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], @@ -79,7 +81,7 @@ return [ 'strict' => true, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ - (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), + (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], diff --git a/config/filesystems.php b/config/filesystems.php index 3d671bd..8f05061 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -1,5 +1,7 @@ explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + 'stateful' => explode(',', (string) env('SANCTUM_STATEFUL_DOMAINS', sprintf( '%s%s', 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', Sanctum::currentApplicationUrlWithPort(), @@ -76,9 +81,9 @@ return [ */ 'middleware' => [ - 'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, - 'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, - 'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, + 'authenticate_session' => AuthenticateSession::class, + 'encrypt_cookies' => EncryptCookies::class, + 'validate_csrf_token' => ValidateCsrfToken::class, ], ]; diff --git a/config/scramble.php b/config/scramble.php index e2ee332..d84d464 100644 --- a/config/scramble.php +++ b/config/scramble.php @@ -1,5 +1,7 @@ + * @extends Factory */ -class UserFactory extends Factory +final class UserFactory extends Factory { /** * The current password being used by the factory. */ - protected static ?string $password; + private static ?string $password = null; /** * Define the model's default state. @@ -27,7 +30,7 @@ class UserFactory extends Factory 'name' => fake()->name(), 'email' => fake()->unique()->safeEmail(), 'email_verified_at' => now(), - 'password' => static::$password ??= Hash::make('password'), + 'password' => self::$password ??= Hash::make('password'), 'remember_token' => Str::random(10), ]; } @@ -37,7 +40,7 @@ class UserFactory extends Factory */ public function unverified(): static { - return $this->state(fn (array $attributes) => [ + return $this->state(fn (array $attributes): array => [ 'email_verified_at' => null, ]); } diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php index 05fb5d9..28df0a8 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -1,5 +1,7 @@ id(); $table->string('name'); $table->string('email')->unique(); @@ -21,13 +23,13 @@ return new class extends Migration $table->timestamps(); }); - Schema::create('password_reset_tokens', function (Blueprint $table) { + Schema::create('password_reset_tokens', function (Blueprint $table): void { $table->string('email')->primary(); $table->string('token'); $table->timestamp('created_at')->nullable(); }); - Schema::create('sessions', function (Blueprint $table) { + Schema::create('sessions', function (Blueprint $table): void { $table->string('id')->primary(); $table->foreignId('user_id')->nullable()->index(); $table->string('ip_address', 45)->nullable(); diff --git a/database/migrations/0001_01_01_000001_create_cache_table.php b/database/migrations/0001_01_01_000001_create_cache_table.php index b9c106b..3b7f6a1 100644 --- a/database/migrations/0001_01_01_000001_create_cache_table.php +++ b/database/migrations/0001_01_01_000001_create_cache_table.php @@ -1,5 +1,7 @@ string('key')->primary(); $table->mediumText('value'); $table->integer('expiration'); }); - Schema::create('cache_locks', function (Blueprint $table) { + Schema::create('cache_locks', function (Blueprint $table): void { $table->string('key')->primary(); $table->string('owner'); $table->integer('expiration'); diff --git a/database/migrations/0001_01_01_000002_create_jobs_table.php b/database/migrations/0001_01_01_000002_create_jobs_table.php index 425e705..dd23ee3 100644 --- a/database/migrations/0001_01_01_000002_create_jobs_table.php +++ b/database/migrations/0001_01_01_000002_create_jobs_table.php @@ -1,5 +1,7 @@ id(); $table->string('queue')->index(); $table->longText('payload'); @@ -21,7 +23,7 @@ return new class extends Migration $table->unsignedInteger('created_at'); }); - Schema::create('job_batches', function (Blueprint $table) { + Schema::create('job_batches', function (Blueprint $table): void { $table->string('id')->primary(); $table->string('name'); $table->integer('total_jobs'); @@ -34,7 +36,7 @@ return new class extends Migration $table->integer('finished_at')->nullable(); }); - Schema::create('failed_jobs', function (Blueprint $table) { + Schema::create('failed_jobs', function (Blueprint $table): void { $table->id(); $table->string('uuid')->unique(); $table->text('connection'); diff --git a/database/migrations/2025_12_25_051601_create_personal_access_tokens_table.php b/database/migrations/2025_12_25_051601_create_personal_access_tokens_table.php index 40ff706..7fa15ff 100644 --- a/database/migrations/2025_12_25_051601_create_personal_access_tokens_table.php +++ b/database/migrations/2025_12_25_051601_create_personal_access_tokens_table.php @@ -1,5 +1,7 @@ id(); $table->morphs('tokenable'); $table->text('name'); diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 6b901f8..3fc4f48 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -1,12 +1,14 @@ withSetProviders(LaravelSetProvider::class) + ->withSets([ + LaravelSetList::LARAVEL_ARRAYACCESS_TO_METHOD_CALL, + LaravelSetList::LARAVEL_ARRAY_STR_FUNCTION_TO_STATIC_CALL, + LaravelSetList::LARAVEL_CODE_QUALITY, + LaravelSetList::LARAVEL_COLLECTION, + LaravelSetList::LARAVEL_CONTAINER_STRING_TO_FULLY_QUALIFIED_NAME, + LaravelSetList::LARAVEL_ELOQUENT_MAGIC_METHOD_TO_QUERY_BUILDER, + LaravelSetList::LARAVEL_FACADE_ALIASES_TO_FULL_NAMES, + LaravelSetList::LARAVEL_FACTORIES, + LaravelSetList::LARAVEL_IF_HELPERS, + LaravelSetList::LARAVEL_LEGACY_FACTORIES_TO_CLASSES, + ]) + ->withImportNames( + removeUnusedImports: true, + ) + ->withComposerBased(laravel: true) + ->withCache( + cacheDirectory: '/tmp/rector', + cacheClass: FileCacheStorage::class, + ) + ->withPaths([ + __DIR__.'/app', + __DIR__.'/bootstrap/app.php', + __DIR__.'/config', + __DIR__.'/database', + __DIR__.'/public', + __DIR__.'/routes', + __DIR__.'/tests', + ]) + ->withSkip([ + AddOverrideAttributeToOverriddenMethodsRector::class, + __DIR__.'/config/database.php', + ]) + ->withPreparedSets( + deadCode: true, + codeQuality: true, + typeDeclarations: true, + privatization: true, + earlyReturn: true, + codingStyle: true, + ) + ->withPhpSets(); diff --git a/routes/api.php b/routes/api.php index c09e1e7..f38c81b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,5 +1,7 @@ group(function () { +Route::middleware('throttle:auth')->group(function (): void { Route::post('register', [AuthController::class, 'register'])->name('api.v1.register'); Route::post('login', [AuthController::class, 'login'])->name('api.v1.login'); }); // Protected routes with authenticated rate limiter (120/min) -Route::middleware(['auth:sanctum', 'throttle:authenticated'])->group(function () { +Route::middleware(['auth:sanctum', 'throttle:authenticated'])->group(function (): void { Route::post('logout', [AuthController::class, 'logout'])->name('api.v1.logout'); Route::get('me', [AuthController::class, 'me'])->name('api.v1.me'); }); diff --git a/routes/console.php b/routes/console.php index 3c9adf1..05f18f2 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,8 +1,10 @@ comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); diff --git a/routes/web.php b/routes/web.php index 2d813c9..3e3ec8f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,4 +1,6 @@ postJson('/api/v1/register', [ 'name' => 'Test User', 'email' => 'test@example.com', @@ -33,7 +35,7 @@ describe('Registration', function () { ]); }); - it('fails registration with invalid data', function () { + it('fails registration with invalid data', function (): void { $response = $this->postJson('/api/v1/register', [ 'name' => '', 'email' => 'invalid-email', @@ -43,7 +45,7 @@ describe('Registration', function () { $response->assertStatus(422); }); - it('fails registration with duplicate email', function () { + it('fails registration with duplicate email', function (): void { User::factory()->create(['email' => 'existing@example.com']); $response = $this->postJson('/api/v1/register', [ @@ -57,8 +59,8 @@ describe('Registration', function () { }); }); -describe('Login', function () { - it('logs in with valid credentials', function () { +describe('Login', function (): void { + it('logs in with valid credentials', function (): void { $user = User::factory()->create([ 'password' => bcrypt('password123'), ]); @@ -83,7 +85,7 @@ describe('Login', function () { ]); }); - it('fails login with invalid credentials', function () { + it('fails login with invalid credentials', function (): void { $user = User::factory()->create([ 'password' => bcrypt('password123'), ]); @@ -100,7 +102,7 @@ describe('Login', function () { ]); }); - it('fails login with non-existent user', function () { + it('fails login with non-existent user', function (): void { $response = $this->postJson('/api/v1/login', [ 'email' => 'nonexistent@example.com', 'password' => 'password123', @@ -110,12 +112,12 @@ describe('Login', function () { }); }); -describe('Logout', function () { - it('logs out authenticated user', function () { +describe('Logout', function (): void { + it('logs out authenticated user', function (): void { $user = User::factory()->create(); $token = $user->createToken('test-token')->plainTextToken; - $response = $this->withHeader('Authorization', "Bearer {$token}") + $response = $this->withHeader('Authorization', 'Bearer '.$token) ->postJson('/api/v1/logout'); $response->assertStatus(200) @@ -125,19 +127,19 @@ describe('Logout', function () { ]); }); - it('fails logout without authentication', function () { + it('fails logout without authentication', function (): void { $response = $this->postJson('/api/v1/logout'); $response->assertStatus(401); }); }); -describe('Me', function () { - it('returns authenticated user data', function () { +describe('Me', function (): void { + it('returns authenticated user data', function (): void { $user = User::factory()->create(); $token = $user->createToken('test-token')->plainTextToken; - $response = $this->withHeader('Authorization', "Bearer {$token}") + $response = $this->withHeader('Authorization', 'Bearer '.$token) ->getJson('/api/v1/me'); $response->assertStatus(200) @@ -155,7 +157,7 @@ describe('Me', function () { ]); }); - it('fails without authentication', function () { + it('fails without authentication', function (): void { $response = $this->getJson('/api/v1/me'); $response->assertStatus(401); diff --git a/tests/Pest.php b/tests/Pest.php index 60f04a4..4668bd3 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,5 +1,9 @@ extend(Tests\TestCase::class) +pest()->extend(TestCase::class) // ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) ->in('Feature'); @@ -26,9 +30,7 @@ pest()->extend(Tests\TestCase::class) | */ -expect()->extend('toBeOne', function () { - return $this->toBe(1); -}); +expect()->extend('toBeOne', fn () => $this->toBe(1)); /* |-------------------------------------------------------------------------- @@ -41,7 +43,7 @@ expect()->extend('toBeOne', function () { | */ -function something() +function something(): void { // .. } diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php index 5773b0c..2a3cf02 100644 --- a/tests/Unit/ExampleTest.php +++ b/tests/Unit/ExampleTest.php @@ -1,10 +1,12 @@