Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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
56 changes: 51 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -435,15 +436,60 @@ it('requires authentication', function () {
});
```

## Development Commands
## 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
# Code formatting (Laravel Pint)
docker compose run --rm app ./vendor/bin/pint
docker compose exec app composer lint
docker compose exec app composer test
```

### Strict Rules Applied

# Check code style without fixing
docker compose run --rm app ./vendor/bin/pint --test
- `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
# List all routes
docker compose run --rm app php artisan route:list

Expand Down
2 changes: 2 additions & 0 deletions app/Http/Controllers/Api/ApiController.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
Expand Down
12 changes: 8 additions & 4 deletions app/Http/Controllers/Api/V1/AuthController.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Api\ApiController;
Expand All @@ -11,11 +13,11 @@
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;

class AuthController extends ApiController
final class AuthController extends ApiController
{
public function register(RegisterRequest $request): JsonResponse
{
$user = User::create([
$user = User::query()->create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
Expand All @@ -31,7 +33,7 @@ public function register(RegisterRequest $request): JsonResponse

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');
Expand All @@ -47,7 +49,9 @@ public function login(LoginRequest $request): JsonResponse

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');
}
Expand Down
2 changes: 2 additions & 0 deletions app/Http/Controllers/Controller.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

abstract class Controller
Expand Down
11 changes: 9 additions & 2 deletions app/Http/Requests/Api/V1/LoginRequest.php
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests\Api\V1;

use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;

class LoginRequest extends FormRequest
/**
* @property string $email
* @property string $password
*/
final class LoginRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}

/**
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
Expand Down
12 changes: 10 additions & 2 deletions app/Http/Requests/Api/V1/RegisterRequest.php
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests\Api\V1;

use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;

class RegisterRequest extends FormRequest
/**
* @property string $name
* @property string $email
* @property string $password
*/
final class RegisterRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}

/**
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
Expand Down
7 changes: 5 additions & 2 deletions app/Http/Resources/UserResource.php
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
<?php

declare(strict_types=1);

namespace App\Http\Resources;

use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

/**
* @mixin \App\Models\User
* @mixin User
*/
class UserResource extends JsonResource
final class UserResource extends JsonResource
{
/**
* @return array<string, mixed>
Expand Down
23 changes: 20 additions & 3 deletions app/Models/User.php
Original file line number Diff line number Diff line change
@@ -1,17 +1,34 @@
<?php

declare(strict_types=1);

namespace App\Models;

// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Carbon;
use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
/**
* @property int $id
* @property string $name
* @property string $email
* @property Carbon|null $email_verified_at
* @property string $password
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*/
final class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasApiTokens, HasFactory, Notifiable;
use HasApiTokens;

/** @use HasFactory<UserFactory> */
use HasFactory;

use Notifiable;

/**
* The attributes that are mass assignable.
Expand Down
22 changes: 9 additions & 13 deletions app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
<?php

declare(strict_types=1);

namespace App\Providers;

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
final class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
Expand All @@ -28,23 +30,17 @@ public function boot(): void
/**
* Configure the rate limiters for the application.
*/
protected function configureRateLimiting(): void
private function configureRateLimiting(): void
{
// Default API rate limiter - 60 requests per minute
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->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()));
}
}
10 changes: 9 additions & 1 deletion app/Traits/ApiResponse.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<?php

declare(strict_types=1);

namespace App\Traits;

use Illuminate\Http\JsonResponse;
Expand Down Expand Up @@ -31,6 +33,9 @@ protected function noContent(): JsonResponse
return response()->json(null, Response::HTTP_NO_CONTENT);
}

/**
* @param array<string, mixed> $errors
*/
protected function error(
string $message = 'Error',
int $code = Response::HTTP_BAD_REQUEST,
Expand All @@ -41,7 +46,7 @@ protected function error(
'message' => $message,
];

if (! empty($errors)) {
if ($errors !== []) {
$response['errors'] = $errors;
}

Expand All @@ -63,6 +68,9 @@ protected function forbidden(string $message = 'Forbidden'): JsonResponse
return $this->error($message, Response::HTTP_FORBIDDEN);
}

/**
* @param array<string, mixed> $errors
*/
protected function validationError(array $errors, string $message = 'Validation failed'): JsonResponse
{
return $this->error($message, Response::HTTP_UNPROCESSABLE_ENTITY, $errors);
Expand Down
2 changes: 2 additions & 0 deletions bootstrap/app.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<?php

declare(strict_types=1);

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
Expand Down
Loading