18 Commits

Author SHA1 Message Date
Jean-Marc Strauven
9c1f450c4b chore(release): 2.1.0 2026-01-20 21:00:25 +01:00
Jean-Marc Strauven
f417ec23c2 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
2026-01-20 20:58:32 +01:00
Jean-Marc Strauven
d961b44c30 chore(release): v2.0.2 (#11) 2026-01-08 20:26:48 +01:00
Jean-Marc Strauven
2b0f6cedaa chore: update dependencies and fix apiroute config (#10)
- Update all Composer dependencies to latest versions
- Fix apiroute URI prefix configuration (was empty, now 'api')
- Laravel framework 12.44.0 -> 12.46.0
- Pest 3.8.4 -> 4.3.1
- laravel-apiroute 0.0.3 -> 2.0.3
2026-01-08 20:24:57 +01:00
Jean-Marc Strauven
bbc34935c6 Merge pull request #8 from Grazulex/fix/routes-cleanup
fix: clean up routes after merge conflict
2026-01-02 17:54:14 +01:00
Jean-Marc Strauven
fcf6483127 fix: clean up routes after merge conflict 2026-01-02 17:53:45 +01:00
Jean-Marc Strauven
6c24d290f4 Merge pull request #7 from Grazulex/feat/apiroute-v2
feat!: upgrade to laravel-apiroute v2.0
2026-01-02 17:50:00 +01:00
Jean-Marc Strauven
396207d6b0 Merge branch 'main' into feat/apiroute-v2 2026-01-02 17:49:51 +01:00
Jean-Marc Strauven
7a35de80d0 feat!: upgrade to laravel-apiroute v2.0 with config-based versioning
- Update composer.json to require grazulex/laravel-apiroute ^2.0
- Add versions config to config/apiroute.php
- Create routes/api/v1.php with version 1 routes
- Update routes/api.php to remove imperative version declarations
- Update README.md with new v2.x documentation

BREAKING CHANGE: API versions are now declared in config/apiroute.php
instead of routes/api.php
2026-01-02 17:47:38 +01:00
Jean-Marc Strauven
9f0d41d827 Merge pull request #5 from Grazulex/fix/php-requirement-rate-limiting
fix: align PHP requirement and implement rate limiting
2025-12-30 15:35:29 +01:00
Jean-Marc Strauven
562448f576 Merge branch 'main' into fix/php-requirement-rate-limiting 2025-12-30 15:35:22 +01:00
Jean-Marc Strauven
cb911a3c04 Merge pull request #4 from Trpsky/feature
Fix : laravel-apiroute version mismatch and upgrade Pest to v4 for Laravel 12 compatibility
2025-12-30 15:34:33 +01:00
Jean-Marc Strauven
06ee9a5016 fix: align PHP requirement and implement rate limiting
- Update composer.json to require PHP ^8.3 (required by grazulex/laravel-apiroute ^1.2)
- Add rate limiting to routes using laravel-apiroute's rateLimit() and Laravel's throttle middleware
  - Public routes (login/register): throttle:auth (5/min for brute force protection)
  - Protected routes: throttle:authenticated (120/min)
  - Global version rate limit: 60 req/min via ->rateLimit(60)
- Remove unused import in routes/web.php (fixes Pint style issue)
- Update composer.lock with synchronized dependencies
2025-12-30 15:33:20 +01:00
ELMEHDI ACHAHED
0113b0112b Fix : dependency mismatch and upgrade Pest for Laravel 12 support
This commit resolves the version mismatch for 'grazulex/laravel-apiroute' by regenerating the lock file. Additionally, it upgrades 'pestphp/pest' and 'pestphp/pest-plugin-laravel' to v4 to ensure compatibility with Laravel 12 and PHPUnit 12, fixing dependency conflicts.
The lock file was out of sync with composer.json, causing installation failures. Upgrading to Pest v4 is necessary as Pest v3 conflicts with PHPUnit 12, which is required by Laravel 12.
2025-12-30 11:05:45 +01:00
Jean-Marc Strauven
ae8d4bd432 Merge pull request #1 from Grazulex/chore/update-laravel-apiroute-1.2.0
chore(deps): update laravel-apiroute to ^1.2
2025-12-28 19:16:16 +01:00
Jean-Marc Strauven
c9fbf64472 chore(deps): update laravel-apiroute to ^1.2 2025-12-28 19:15:17 +01:00
Jean-Marc Strauven
d93b82b1eb chore(release): 1.1.0 2025-12-25 07:33:38 +01:00
Jean-Marc Strauven
02c55f765f chore(deps): update laravel-apiroute to ^1.0
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 07:33:08 +01:00
47 changed files with 1334 additions and 651 deletions

55
.github/workflows/tests.yml vendored Normal file
View File

@@ -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

View File

@@ -2,6 +2,25 @@
All notable changes to this project will be documented in this file.
## [2.1.0](https://github.com/Grazulex/laravel-api-kit/releases/tag/v2.1.0) (2026-01-20)
### Features
- integrate PHPStan, Rector and Pint code quality tools (#13) ([f417ec2](https://github.com/Grazulex/laravel-api-kit/commit/f417ec23c2eada4eaddf3ec9e4cff7ccc71a3572))
## [2.0.2](https://github.com/Grazulex/laravel-api-kit/releases/tag/v2.0.2) (2026-01-08)
### Bug Fixes
- clean up routes after merge conflict ([fcf6483](https://github.com/Grazulex/laravel-api-kit/commit/fcf64831272a1aaf20fde0d8001714994ccc6932))
### Chores
- update dependencies and fix apiroute config (#10) ([2b0f6ce](https://github.com/Grazulex/laravel-api-kit/commit/2b0f6cedaa2e7804d0a2a30c0d8c8d0dc27c2f57))
## [1.1.0](https://github.com/Grazulex/laravel-api-kit/releases/tag/v1.1.0) (2025-12-25)
### Chores
- **deps:** update laravel-apiroute to ^1.0 ([02c55f7](https://github.com/Grazulex/laravel-api-kit/commit/02c55f765f103852258398d7d0d0790146a33f10))
## [1.0.0](https://github.com/Grazulex/laravel-api-kit/releases/tag/v1.0.0) (2025-12-25)
### Features

115
README.md
View File

@@ -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
@@ -230,7 +231,9 @@ laravel-api-kit/
│ ├── sanctum.php # Token auth config
│ └── scramble.php # API docs config
├── routes/
── api.php # API routes with versioning
── api.php # API routes entry point
│ └── api/
│ └── v1.php # Version 1 routes
├── tests/
│ └── Feature/Api/V1/
│ └── AuthTest.php # Authentication tests
@@ -241,7 +244,7 @@ laravel-api-kit/
## API Versioning
This kit uses [grazulex/laravel-apiroute](https://github.com/Grazulex/laravel-apiroute) for API versioning with support for:
This kit uses [grazulex/laravel-apiroute](https://github.com/Grazulex/laravel-apiroute) v2.x for API versioning with support for:
- **URI Path** (default): `/api/v1/users`, `/api/v2/users`
- **Header**: `X-API-Version: 2`
@@ -252,22 +255,34 @@ This kit uses [grazulex/laravel-apiroute](https://github.com/Grazulex/laravel-ap
1. Create controllers in `app/Http/Controllers/Api/V2/`
2. Create requests in `app/Http/Requests/Api/V2/`
3. Update `routes/api.php`:
3. Create route file `routes/api/v2.php`:
```php
use Grazulex\ApiRoute\Facades\ApiRoute;
<?php
// Version 2 - New current version
ApiRoute::version('v2', function () {
Route::post('register', [V2\AuthController::class, 'register']);
// ... more routes
})->current();
use App\Http\Controllers\Api\V2\AuthController;
use Illuminate\Support\Facades\Route;
// Version 1 - Mark as deprecated
ApiRoute::version('v1', function () {
Route::post('register', [V1\AuthController::class, 'register']);
// ... existing routes
})->deprecated('2025-06-01')->sunset('2025-12-01');
Route::post('register', [AuthController::class, 'register']);
// ... more routes
```
4. Update `config/apiroute.php`:
```php
'versions' => [
'v1' => [
'routes' => base_path('routes/api/v1.php'),
'status' => 'deprecated',
'deprecated_at' => '2025-06-01',
'sunset_at' => '2025-12-01',
'successor' => 'v2',
],
'v2' => [
'routes' => base_path('routes/api/v2.php'),
'status' => 'active',
],
],
```
### Deprecation Headers
@@ -275,8 +290,8 @@ ApiRoute::version('v1', function () {
When accessing deprecated versions, responses include RFC-compliant headers:
```http
Deprecation: @1717200000
Sunset: Sun, 01 Dec 2025 00:00:00 GMT
Deprecation: Sun, 01 Jun 2025 00:00:00 GMT
Sunset: Mon, 01 Dec 2025 00:00:00 GMT
Link: </api/v2>; rel="successor-version"
```
@@ -421,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
@@ -574,14 +634,11 @@ class PostResource extends JsonResource
4. **Add Routes:**
```php
// routes/api.php
ApiRoute::version('v1', function () {
// routes/api/v1.php
Route::middleware('auth:sanctum')->group(function () {
// ... existing routes
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('posts', PostController::class);
});
})->current();
Route::apiResource('posts', PostController::class);
});
```
5. **Create Tests:**

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Api\ApiController;
@@ -11,11 +13,11 @@ use Illuminate\Http\JsonResponse;
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),
@@ -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');
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
abstract class Controller

View File

@@ -1,10 +1,17 @@
<?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
{
@@ -12,7 +19,7 @@ class LoginRequest extends FormRequest
}
/**
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{

View File

@@ -1,10 +1,18 @@
<?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
{
@@ -12,7 +20,7 @@ class RegisterRequest extends FormRequest
}
/**
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{

View File

@@ -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>

View File

@@ -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.

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
@@ -7,7 +9,7 @@ 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.
@@ -28,23 +30,17 @@ class AppServiceProvider extends ServiceProvider
/**
* 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()));
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Traits;
use Illuminate\Http\JsonResponse;
@@ -31,6 +33,9 @@ trait ApiResponse
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,
@@ -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<string, mixed> $errors
*/
protected function validationError(array $errors, string $message = 'Validation failed'): JsonResponse
{
return $this->error($message, Response::HTTP_UNPROCESSABLE_ENTITY, $errors);

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
return [
App\Providers\AppServiceProvider::class,
];

View File

@@ -6,9 +6,9 @@
"keywords": ["laravel", "api", "rest", "starter-kit", "sanctum"],
"license": "MIT",
"require": {
"php": "^8.2",
"php": "^8.3",
"dedoc/scramble": "^0.12",
"grazulex/laravel-apiroute": "^0.0",
"grazulex/laravel-apiroute": "^2.0",
"laravel/framework": "^12.0",
"laravel/sanctum": "^4.0",
"laravel/tinker": "^2.10.1",
@@ -16,13 +16,16 @@
"spatie/laravel-query-builder": "^6.0"
},
"require-dev": {
"driftingly/rector-laravel": "^2.0",
"fakerphp/faker": "^1.23",
"larastan/larastan": "^3.7",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.24",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"pestphp/pest": "^3.0",
"pestphp/pest-plugin-laravel": "^3.0"
"pestphp/pest": "^4.0",
"pestphp/pest-plugin-laravel": "^4.0",
"rector/rector": "^2.2"
},
"autoload": {
"psr-4": {
@@ -37,10 +40,26 @@
}
},
"scripts": {
"test": [
"lint": [
"rector",
"pint"
],
"test:lint": [
"pint --test",
"rector --dry-run"
],
"test:types": [
"phpstan"
],
"test:unit": [
"@php artisan config:clear --ansi",
"@php artisan test"
],
"test": [
"@test:lint",
"@test:types",
"@test:unit"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"

1304
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,28 @@
declare(strict_types=1);
return [
/*
|--------------------------------------------------------------------------
| API Versions
|--------------------------------------------------------------------------
|
| Define your API versions here. Each version has its own route file,
| middleware, and lifecycle status.
|
*/
'versions' => [
'v1' => [
'routes' => base_path('routes/api/v1.php'),
'middleware' => [],
'status' => 'active',
'deprecated_at' => null,
'sunset_at' => null,
'successor' => null,
'documentation' => null,
'rate_limit' => null,
],
],
/*
|--------------------------------------------------------------------------
| Detection Strategy
@@ -21,7 +43,7 @@ return [
*/
'strategies' => [
'uri' => [
'prefix' => '', // Empty - Laravel 12 adds /api prefix in bootstrap/app.php
'prefix' => 'api', // API prefix for versioned routes
'pattern' => 'v{version}', // v1, v2, etc.
],
'header' => [

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
return [
/*

View File

@@ -1,5 +1,9 @@
<?php
declare(strict_types=1);
use App\Models\User;
return [
/*
@@ -62,7 +66,7 @@ return [
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', App\Models\User::class),
'model' => env('AUTH_MODEL', User::class),
],
// 'users' => [

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Str;
return [

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
return [
/*

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Str;
return [
@@ -59,7 +61,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'),
]) : [],
],
@@ -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'),
]) : [],
],

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
return [
/*

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
return [
/*

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
return [
/*

View File

@@ -1,5 +1,10 @@
<?php
declare(strict_types=1);
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
use Laravel\Sanctum\Http\Middleware\AuthenticateSession;
use Laravel\Sanctum\Sanctum;
return [
@@ -15,7 +20,7 @@ return [
|
*/
'stateful' => 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,
],
];

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
use Dedoc\Scramble\Http\Middleware\RestrictedDocsAccess;
return [

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
return [
/*

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Str;
return [

View File

@@ -1,20 +1,23 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
* @extends Factory<User>
*/
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,
]);
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
@@ -11,7 +13,7 @@ return new class extends Migration
*/
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
Schema::create('users', function (Blueprint $table): void {
$table->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();

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
@@ -11,13 +13,13 @@ return new class extends Migration
*/
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
Schema::create('cache', function (Blueprint $table): void {
$table->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');

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
@@ -11,7 +13,7 @@ return new class extends Migration
*/
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
Schema::create('jobs', function (Blueprint $table): void {
$table->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');

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
@@ -11,7 +13,7 @@ return new class extends Migration
*/
public function up(): void
{
Schema::create('personal_access_tokens', function (Blueprint $table) {
Schema::create('personal_access_tokens', function (Blueprint $table): void {
$table->id();
$table->morphs('tokenable');
$table->text('name');

View File

@@ -1,12 +1,14 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
final class DatabaseSeeder extends Seeder
{
use WithoutModelEvents;

21
phpstan.neon Normal file
View File

@@ -0,0 +1,21 @@
includes:
- vendor/larastan/larastan/extension.neon
- vendor/nesbot/carbon/extension.neon
- phar://phpstan.phar/conf/bleedingEdge.neon
parameters:
paths:
- app
- bootstrap/app.php
- config
- database
- public
- routes
excludePaths:
- config/database.php
level: max
tmpDir: /tmp/phpstan

63
pint.json Normal file
View File

@@ -0,0 +1,63 @@
{
"preset": "laravel",
"notPath": [
"tests/TestCase.php",
"tmp",
"config/database.php"
],
"rules": {
"array_push": true,
"backtick_to_shell_exec": true,
"date_time_immutable": true,
"declare_strict_types": true,
"lowercase_keywords": true,
"lowercase_static_reference": true,
"final_class": true,
"final_internal_class": true,
"final_public_method_for_abstract_class": true,
"fully_qualified_strict_types": true,
"global_namespace_import": {
"import_classes": true,
"import_constants": true,
"import_functions": true
},
"mb_str_functions": true,
"modernize_types_casting": true,
"new_with_parentheses": false,
"no_superfluous_elseif": true,
"no_useless_else": true,
"no_multiple_statements_per_line": true,
"ordered_class_elements": {
"order": [
"use_trait",
"case",
"constant",
"constant_public",
"constant_protected",
"constant_private",
"property_public",
"property_protected",
"property_private",
"construct",
"destruct",
"magic",
"phpunit",
"method_abstract",
"method_public_static",
"method_public",
"method_protected_static",
"method_protected",
"method_private_static",
"method_private"
],
"sort_algorithm": "none"
},
"ordered_interfaces": true,
"ordered_traits": true,
"protected_to_private": true,
"self_accessor": true,
"self_static_accessor": true,
"strict_comparison": true,
"visibility_required": true
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;

54
rector.php Normal file
View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
use Rector\Caching\ValueObject\Storage\FileCacheStorage;
use Rector\Config\RectorConfig;
use Rector\Php83\Rector\ClassMethod\AddOverrideAttributeToOverriddenMethodsRector;
use RectorLaravel\Set\LaravelSetList;
use RectorLaravel\Set\LaravelSetProvider;
return RectorConfig::configure()
->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();

View File

@@ -1,29 +1,20 @@
<?php
use App\Http\Controllers\Api\V1\AuthController;
use Grazulex\ApiRoute\Facades\ApiRoute;
use Illuminate\Support\Facades\Route;
declare(strict_types=1);
/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| API routes are versioned using grazulex/laravel-apiroute.
| API routes are versioned using grazulex/laravel-apiroute v2.x.
| Versions are defined in config/apiroute.php and route files are
| located in routes/api/{version}.php
|
| Supports URI path, header, query, and Accept header detection.
| See config/apiroute.php for configuration options.
|
*/
// Version 1 - Current stable version
ApiRoute::version('v1', function () {
// Public routes
Route::post('register', [AuthController::class, 'register'])->name('api.v1.register');
Route::post('login', [AuthController::class, 'login'])->name('api.v1.login');
// Protected routes
Route::middleware('auth:sanctum')->group(function () {
Route::post('logout', [AuthController::class, 'logout'])->name('api.v1.logout');
Route::get('me', [AuthController::class, 'me'])->name('api.v1.me');
});
})->current();
// Routes are now loaded automatically from config/apiroute.php
// See routes/api/v1.php for version 1 routes

27
routes/api/v1.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
use App\Http\Controllers\Api\V1\AuthController;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| API V1 Routes
|--------------------------------------------------------------------------
|
| Routes for API version 1.
|
*/
// Public routes with auth rate limiter (5/min - brute force protection)
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 (): void {
Route::post('logout', [AuthController::class, 'logout'])->name('api.v1.logout');
Route::get('me', [AuthController::class, 'me'])->name('api.v1.me');
});

View File

@@ -1,8 +1,10 @@
<?php
declare(strict_types=1);
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
Artisan::command('inspire', function () {
Artisan::command('inspire', function (): void {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');

View File

@@ -1,6 +1,6 @@
<?php
use Illuminate\Support\Facades\Route;
declare(strict_types=1);
// Web routes disabled - API only application
// Scramble documentation available at /docs/api

View File

@@ -1,12 +1,14 @@
<?php
declare(strict_types=1);
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
describe('Registration', function () {
it('registers a new user successfully', function () {
describe('Registration', function (): void {
it('registers a new user successfully', function (): void {
$response = $this->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);

View File

@@ -1,5 +1,9 @@
<?php
declare(strict_types=1);
use Tests\TestCase;
/*
|--------------------------------------------------------------------------
| Test Case
@@ -11,7 +15,7 @@
|
*/
pest()->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
{
// ..
}

View File

@@ -1,10 +1,12 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
class ExampleTest extends TestCase
final class ExampleTest extends TestCase
{
/**
* A basic test example.