29 Commits
V1.2.0 ... main

Author SHA1 Message Date
Jean-Marc Strauven
98b8ff236b Merge pull request #23 from Grazulex/feat/laravel-13-upgrade
feat!: upgrade to Laravel 13 compatibility
2026-03-30 18:03:04 +02:00
Jean-Marc Strauven
53913c1596 fix(ci): set platform PHP to 8.3 for CI compatibility
The platform PHP was set to 8.4.5 which caused Symfony to resolve
to v8.0 requiring PHP 8.4+, breaking the PHP 8.3 CI job.
2026-03-30 18:02:08 +02:00
Jean-Marc Strauven
f2dcd0059a feat!: upgrade to Laravel 13 compatibility
- Update laravel/framework to ^13.0, tinker to ^3.0, query-builder to ^7.0
- Migrate User model to #[Fillable] and #[Hidden] attributes (Laravel 13 pattern)
- Add LARAVEL_130 ruleset to Rector configuration
- Add PHP 8.4 to CI test matrix
- Update README badges and description for Laravel 13

BREAKING CHANGE: requires Laravel 13.x, drops Laravel 12 support
2026-03-30 17:59:00 +02:00
Jean-Marc Strauven
c72b740749 Merge pull request #21 from Grazulex/docs/suggest-optional-packages
docs: add idempotency and throttle-smart as suggested packages
2026-02-05 05:32:52 +01:00
Jean-Marc Strauven
2791415401 docs: add laravel-api-idempotency and laravel-api-throttle-smart as suggested packages
- Add both packages to composer.json suggest section
- Document usage, installation and attention points in README
- Add to Features list and Credits section
2026-02-05 05:32:14 +01:00
Jean-Marc Strauven
ada996848a Merge pull request #20 from Grazulex/chore/update-dependencies
chore(deps): update Composer dependencies
2026-02-02 09:49:43 +01:00
Jean-Marc Strauven
43a36e4913 chore(deps): update Composer dependencies
Update all dependencies to their latest PHP 8.3 compatible versions:
- laravel/framework v12.46.0 → v12.49.0
- laravel/sanctum v4.2.2 → v4.3.0
- grazulex/laravel-apiroute v2.0.3 → v2.0.5
- spatie/laravel-data 4.18.0 → 4.19.1
- spatie/laravel-query-builder 6.3.6 → 6.4.1
- phpstan/phpstan 2.1.35 → 2.1.38
- pestphp/pest v4.3.1 → v4.3.2
- rector/rector 2.3.2 → 2.3.5

Added platform PHP 8.3 constraint to prevent Symfony 8.x packages
from being installed (requires PHP 8.4).
2026-02-02 09:48:37 +01:00
Jean-Marc Strauven
8b0b57808b Merge pull request #19 from Grazulex/Grazulex-patch-1
Update funding information in FUNDING.yml
2026-02-02 05:22:31 +01:00
Jean-Marc Strauven
5cad524908 Update funding information in FUNDING.yml 2026-02-02 05:22:17 +01:00
Jean-Marc Strauven
84a094666d Merge pull request #18 from Grazulex/release/v2.2.0
chore(release): 2.2.0
2026-01-29 04:21:56 +01:00
Jean-Marc Strauven
5250000618 chore(release): 2.2.0 2026-01-29 04:21:31 +01:00
Jean-Marc Strauven
b62ec456ad chore(release): 2.1.0 2026-01-29 04:20:44 +01:00
Jean-Marc Strauven
03de471bb5 Merge pull request #17 from Grazulex/docs/readme-v2.2.0
docs: add documentation for v2.2.0 features
2026-01-29 04:19:59 +01:00
Jean-Marc Strauven
b14f4f2dd9 docs: add documentation for email verification, password reset and middleware
- Document email verification flow with curl examples
- Document password reset flow with curl examples
- Document 3 middleware patterns (force.json, log.api, verified)
- Update Features section with new capabilities
- Update register response example with email verification message
2026-01-29 04:19:28 +01:00
ELMEHDI ACHAHED
13da9b84c1 Feature (#16)
* feat: add email verification endpoints and tests

Add comprehensive CI/CD pipeline using GitHub Actions

- Run Pest tests on PHP 8.3 and 8.4 with MySQL 8.0
- Automated code style checks with Pint
- Static analysis with Larastan/PHPStan
- Parallel job execution for faster builds
- Composer dependency caching
- MySQL service container with health checks

* feat: implement password reset flow with secure tokens , add forgot password and reset password functionality

- Created password_reset_tokens table migration
- Added POST /api/v1/forgot-password endpoint
- Added POST /api/v1/reset-password endpoint
- Both endpoints rate-limited to 6 requests per minute
- Integrated with Laravel's Password facade for secure token management
- Revokes all user tokens upon successful password reset
- Created ForgotPasswordRequest and ResetPasswordRequest with validation
- Added comprehensive test suite (6 test cases)
Password reset uses signed, time-limited tokens stored in the database.
All user sessions are invalidated after successful reset for security.

* feat: add reusable API middleware examples , Implement three production-ready middleware patterns for APIs

ForceJsonResponse:
- Ensures all API responses are JSON formatted
- Sets Accept: application/json header automatically
- Handles non-JSON responses gracefully
LogApiRequests:
- Logs API requests with timestamp, method, URL, IP, user ID, status
- Tracks and logs response time in milliseconds
- Adds X-Response-Time header to all responses
- Configurable via APP_LOG_API_REQUESTS env variable
EnsureEmailVerified:
- Protects routes requiring verified emails
- Returns 403 with descriptive message for unverified users
- Works with MustVerifyEmail contract
All middleware registered as aliases in bootstrap/app.php:
'force.json', 'log.api', 'verified'
These provide common API patterns that developers can apply to routes as needed.

* docs: update README with new API endpoints , Update API endpoints table with email verification and password reset routes

- Added 4 new endpoint rows to API documentation
- Updated rate limits for protected routes (60/min -> 120/min)
- Documented email verification endpoints
- Documented password reset endpoints
- All new endpoints properly documented with auth requirements"

* Update 0001_01_01_000000_create_users_table.php

* Fix test suite configuration and update route names to standard Laravel conventions

* style: fix code formatting per Laravel Pint standards

* refactor: revert password_reset_tokens back to users create_users migration

* Update 0001_01_01_000000_create_users_table.php

* fix: resolve all code style and type safety issues

Pint fixes:
- Fixed concat_space issues
- Removed unused imports (MustVerifyEmail from EnsureEmailVerified)
- Fixed not_operator_with_successor_space formatting

Rector fixes:
- Applied EncapsedStringsToSprintfRector for better type safety
- All code quality improvements applied

PHPStan fixes:
- Changed all middleware return types from Response to mixed (Laravel standard)
- Added Response type guard in LogApiRequests before accessing methods
- Removed redundant instanceof MustVerifyEmail check (User always implements it)
2026-01-29 04:16:33 +01:00
Jean-Marc Strauven
07c36c0009 chore(release): 2.1.0 (#14) 2026-01-20 21:01:45 +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
57 changed files with 2581 additions and 1031 deletions

4
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,4 @@
# These are supported funding model platforms
github: Grazulex
buy_me_a_coffee: Grazulex
custom: ["https://paypal.me/strauven"]

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

@@ -0,0 +1,59 @@
name: tests
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
ci:
runs-on: ubuntu-latest
strategy:
matrix:
php-version: ['8.3', '8.4']
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
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,36 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [2.2.0](https://github.com/Grazulex/laravel-api-kit/releases/tag/v2.2.0) (2026-01-29)
### Features
- add email verification endpoints with signed URLs (#16)
- add password reset flow with secure tokens (#16)
- add 3 reusable API middleware: ForceJsonResponse, LogApiRequests, EnsureEmailVerified (#16)
### Documentation
- add documentation for email verification, password reset and middleware (#17)
## [2.1.0](https://github.com/Grazulex/laravel-api-kit/releases/tag/v2.1.0) (2026-01-29)
### Features
- integrate PHPStan, Rector and Pint code quality tools (#13)
### Chores
- **release:** 2.1.0 (#14)
## [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) ## [1.1.0](https://github.com/Grazulex/laravel-api-kit/releases/tag/v1.1.0) (2025-12-25)
### Chores ### Chores

351
README.md
View File

@@ -1,22 +1,28 @@
# Laravel API Kit # Laravel API Kit
A production-ready, API-only Laravel 12 starter kit following the 2024-2025 REST API ecosystem best practices. No frontend dependencies - purely headless API for mobile apps, SPAs, or microservices. A production-ready, API-only Laravel 13 starter kit following the 2025-2026 REST API ecosystem best practices. No frontend dependencies - purely headless API for mobile apps, SPAs, or microservices.
[![PHP Version](https://img.shields.io/badge/PHP-8.3%2B-blue)](https://php.net) [![PHP Version](https://img.shields.io/badge/PHP-8.3%2B-blue)](https://php.net)
[![Laravel Version](https://img.shields.io/badge/Laravel-12.x-red)](https://laravel.com) [![Laravel Version](https://img.shields.io/badge/Laravel-13.x-red)](https://laravel.com)
[![License](https://img.shields.io/badge/License-MIT-green)](LICENSE) [![License](https://img.shields.io/badge/License-MIT-green)](LICENSE)
## Features ## Features
- **API-Only** - No Blade, Vite, or frontend assets - **API-Only** - No Blade, Vite, or frontend assets
- **Token Authentication** - Laravel Sanctum for mobile/SPA auth - **Token Authentication** - Laravel Sanctum for mobile/SPA auth
- **Email Verification** - Built-in email verification flow with signed URLs
- **Password Reset** - Secure password reset with token-based flow
- **API Versioning** - URI-based versioning with deprecation support via [grazulex/laravel-apiroute](https://github.com/Grazulex/laravel-apiroute) - **API Versioning** - URI-based versioning with deprecation support via [grazulex/laravel-apiroute](https://github.com/Grazulex/laravel-apiroute)
- **Query Building** - Filtering, sorting, includes via [spatie/laravel-query-builder](https://github.com/spatie/laravel-query-builder) - **Query Building** - Filtering, sorting, includes via [spatie/laravel-query-builder](https://github.com/spatie/laravel-query-builder)
- **Data Objects** - Type-safe DTOs via [spatie/laravel-data](https://github.com/spatie/laravel-data) - **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) - **Auto Documentation** - Zero-annotation OpenAPI 3.1 via [dedoc/scramble](https://github.com/dedoc/scramble)
- **Modern Testing** - Pest PHP with Laravel HTTP testing - **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 - **Rate Limiting** - Configurable per-route rate limiters
- **Reusable Middleware** - ForceJsonResponse, LogApiRequests, EnsureEmailVerified
- **Standardized Responses** - Consistent JSON response format - **Standardized Responses** - Consistent JSON response format
- **Optional: API Idempotency** - RFC-compliant idempotency via [grazulex/laravel-api-idempotency](https://github.com/Grazulex/laravel-api-idempotency)
- **Optional: Smart Rate Limiting** - Plan-aware throttling with quotas via [grazulex/laravel-api-throttle-smart](https://github.com/Grazulex/laravel-api-throttle-smart)
## Requirements ## Requirements
@@ -101,12 +107,13 @@ curl -X POST http://localhost:8080/api/v1/register \
```json ```json
{ {
"success": true, "success": true,
"message": "User registered successfully", "message": "User registered successfully. Please check your email to verify your account.",
"data": { "data": {
"user": { "user": {
"id": 1, "id": 1,
"name": "John Doe", "name": "John Doe",
"email": "john@example.com", "email": "john@example.com",
"email_verified_at": null,
"created_at": "2025-01-15T10:30:00+00:00" "created_at": "2025-01-15T10:30:00+00:00"
}, },
"token": "1|abc123..." "token": "1|abc123..."
@@ -144,16 +151,92 @@ curl -X POST http://localhost:8080/api/v1/logout \
-H "Accept: application/json" -H "Accept: application/json"
``` ```
### Email Verification
After registration, users receive a verification email. The kit integrates with Laravel's `MustVerifyEmail` contract.
**Verify Email (via signed URL from email):**
```bash
curl -X POST "http://localhost:8080/api/v1/email/verify/{id}/{hash}?signature=..." \
-H "Authorization: Bearer 1|abc123..." \
-H "Accept: application/json"
```
**Resend Verification Email:**
```bash
curl -X POST http://localhost:8080/api/v1/email/resend \
-H "Authorization: Bearer 1|abc123..." \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"email": "john@example.com"}'
```
**Response:**
```json
{
"success": true,
"message": "Verification email sent successfully",
"data": null
}
```
### Password Reset
**Request Password Reset Link:**
```bash
curl -X POST http://localhost:8080/api/v1/forgot-password \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"email": "john@example.com"}'
```
**Response:**
```json
{
"success": true,
"message": "Password reset link sent to your email",
"data": null
}
```
**Reset Password (with token from email):**
```bash
curl -X POST http://localhost:8080/api/v1/reset-password \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"email": "john@example.com",
"token": "reset-token-from-email",
"password": "newpassword123",
"password_confirmation": "newpassword123"
}'
```
**Response:**
```json
{
"success": true,
"message": "Password reset successfully",
"data": null
}
```
> **Note:** After a successful password reset, all user tokens are revoked for security.
## API Endpoints ## API Endpoints
### Version 1 (`/api/v1`) ### Version 1 (`/api/v1`)
| Method | Endpoint | Auth | Description | Rate Limit | | Method | Endpoint | Auth | Description | Rate Limit |
|--------|-------------|------|--------------------------|------------| |--------|------------------------------|------|-------------------------------|------------|
| POST | /register | No | Register new user | 5/min | | POST | /register | No | Register new user | 5/min |
| POST | /login | No | Get authentication token | 5/min | | POST | /login | No | Get authentication token | 5/min |
| POST | /logout | Yes | Revoke current token | 60/min | | POST | /logout | Yes | Revoke current token | 120/min |
| GET | /me | Yes | Get current user profile | 60/min | | GET | /me | Yes | Get current user profile | 120/min |
| POST | /email/verify/{id}/{hash} | Yes | Verify email address | 120/min |
| POST | /email/resend | Yes | Resend verification email | 6/min |
| POST | /forgot-password | No | Request password reset link | 6/min |
| POST | /reset-password | No | Reset password with token | 6/min |
## Response Format ## Response Format
@@ -230,7 +313,9 @@ laravel-api-kit/
│ ├── sanctum.php # Token auth config │ ├── sanctum.php # Token auth config
│ └── scramble.php # API docs config │ └── scramble.php # API docs config
├── routes/ ├── routes/
── api.php # API routes with versioning ── api.php # API routes entry point
│ └── api/
│ └── v1.php # Version 1 routes
├── tests/ ├── tests/
│ └── Feature/Api/V1/ │ └── Feature/Api/V1/
│ └── AuthTest.php # Authentication tests │ └── AuthTest.php # Authentication tests
@@ -241,7 +326,7 @@ laravel-api-kit/
## API Versioning ## 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` - **URI Path** (default): `/api/v1/users`, `/api/v2/users`
- **Header**: `X-API-Version: 2` - **Header**: `X-API-Version: 2`
@@ -252,22 +337,34 @@ This kit uses [grazulex/laravel-apiroute](https://github.com/Grazulex/laravel-ap
1. Create controllers in `app/Http/Controllers/Api/V2/` 1. Create controllers in `app/Http/Controllers/Api/V2/`
2. Create requests in `app/Http/Requests/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 ```php
use Grazulex\ApiRoute\Facades\ApiRoute; <?php
// Version 2 - New current version use App\Http\Controllers\Api\V2\AuthController;
ApiRoute::version('v2', function () { use Illuminate\Support\Facades\Route;
Route::post('register', [V2\AuthController::class, 'register']);
// ... more routes
})->current();
// Version 1 - Mark as deprecated Route::post('register', [AuthController::class, 'register']);
ApiRoute::version('v1', function () { // ... more routes
Route::post('register', [V1\AuthController::class, 'register']); ```
// ... existing routes
})->deprecated('2025-06-01')->sunset('2025-12-01'); 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 ### Deprecation Headers
@@ -275,8 +372,8 @@ ApiRoute::version('v1', function () {
When accessing deprecated versions, responses include RFC-compliant headers: When accessing deprecated versions, responses include RFC-compliant headers:
```http ```http
Deprecation: @1717200000 Deprecation: Sun, 01 Jun 2025 00:00:00 GMT
Sunset: Sun, 01 Dec 2025 00:00:00 GMT Sunset: Mon, 01 Dec 2025 00:00:00 GMT
Link: </api/v2>; rel="successor-version" Link: </api/v2>; rel="successor-version"
``` ```
@@ -370,6 +467,140 @@ X-RateLimit-Remaining: 59
Retry-After: 60 # When limit exceeded Retry-After: 60 # When limit exceeded
``` ```
## Optional Packages
The following packages are **suggested** (not required) and can be installed individually to extend the kit's capabilities. They are fully opt-in and will not affect existing behavior.
### API Idempotency
[grazulex/laravel-api-idempotency](https://github.com/Grazulex/laravel-api-idempotency) provides RFC-compliant idempotency for your API endpoints. It prevents duplicate operations when clients retry requests (critical for payments, order creation, etc.).
**Install:**
```bash
composer require grazulex/laravel-api-idempotency
```
**Publish config (optional):**
```bash
php artisan vendor:publish --tag="api-idempotency-config"
```
**Usage — apply the middleware to mutation routes:**
```php
// routes/api/v1.php
Route::middleware(['auth:sanctum', 'throttle:authenticated'])->group(function () {
Route::post('orders', [OrderController::class, 'store'])
->middleware('idempotent');
Route::post('payments', [PaymentController::class, 'store'])
->middleware('idempotent:required'); // Require Idempotency-Key header
});
```
**Client-side — include the `Idempotency-Key` header:**
```bash
curl -X POST http://localhost:8080/api/v1/orders \
-H "Authorization: Bearer 1|abc123..." \
-H "Idempotency-Key: order_unique_key_123" \
-H "Content-Type: application/json" \
-d '{"product_id": 1, "quantity": 2}'
```
> **Attention:**
> - Only apply the `idempotent` middleware to mutation routes (POST, PUT, PATCH). GET requests are naturally idempotent.
> - The default storage driver is `cache`. For production with multiple servers, use the `redis` or `database` driver.
> - Keys are scoped per user by default. Two different users can use the same key without conflict.
---
### Smart Rate Limiting
[grazulex/laravel-api-throttle-smart](https://github.com/Grazulex/laravel-api-throttle-smart) provides plan-aware rate limiting with quotas, multiple algorithms (fixed window, sliding window, token bucket), and multi-tenant support. Ideal for SaaS APIs with subscription tiers.
**Install:**
```bash
composer require grazulex/laravel-api-throttle-smart
```
**Publish config:**
```bash
php artisan vendor:publish --tag="throttle-smart-config"
```
**Usage — apply to routes where plan-based limiting is needed:**
```php
// routes/api/v1.php
Route::middleware(['auth:sanctum', 'throttle.smart'])->group(function () {
Route::apiResource('posts', PostController::class);
});
```
> **Attention:**
> - This package **coexists** with Laravel's built-in `throttle:` middleware. You do not need to remove the existing rate limiters.
> - If you want to **replace** the native throttle on specific routes, swap `throttle:authenticated` with `throttle.smart` on those routes only.
> - Do **not** apply both `throttle:authenticated` and `throttle.smart` on the same route group — choose one per group to avoid double rate limiting.
> - The default driver is `cache`. For production, `redis` is recommended for performance and distributed consistency.
> - Configure your subscription plans in `config/throttle-smart.php` to match your business model (Free, Pro, Enterprise, etc.).
---
## Middleware
The kit includes three production-ready middleware patterns that you can apply to your routes as needed.
### Available Middleware
| Alias | Class | Description |
|-------|-------|-------------|
| `force.json` | `ForceJsonResponse` | Ensures all responses are JSON formatted |
| `log.api` | `LogApiRequests` | Logs API requests with timing information |
| `verified` | `EnsureEmailVerified` | Requires verified email to access route |
### ForceJsonResponse
Automatically sets `Accept: application/json` header and converts non-JSON responses to JSON format.
```php
Route::middleware('force.json')->group(function () {
// All responses will be JSON
});
```
### LogApiRequests
Logs API requests with detailed information and adds `X-Response-Time` header to responses.
**Logged data:** timestamp, method, URL, IP, user ID, status code, duration (ms), user agent.
**Enable logging via environment:**
```env
APP_LOG_API_REQUESTS=true
```
```php
Route::middleware('log.api')->group(function () {
// Requests will be logged
});
```
### EnsureEmailVerified
Protects routes that require a verified email address. Returns 403 if email is not verified.
```php
Route::middleware(['auth:sanctum', 'verified'])->group(function () {
// Only users with verified emails can access
});
```
**Response when email not verified:**
```json
{
"success": false,
"message": "Your email address is not verified. Please verify your email to continue."
}
```
## Testing ## Testing
This kit uses [Pest PHP](https://pestphp.com/) for testing: This kit uses [Pest PHP](https://pestphp.com/) for testing:
@@ -421,15 +652,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 ## Development Commands
```bash ```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 # List all routes
docker compose run --rm app php artisan route:list docker compose run --rm app php artisan route:list
@@ -574,14 +850,11 @@ class PostResource extends JsonResource
4. **Add Routes:** 4. **Add Routes:**
```php ```php
// routes/api.php // routes/api/v1.php
ApiRoute::version('v1', function () { Route::middleware('auth:sanctum')->group(function () {
// ... existing routes // ... existing routes
Route::apiResource('posts', PostController::class);
Route::middleware('auth:sanctum')->group(function () { });
Route::apiResource('posts', PostController::class);
});
})->current();
``` ```
5. **Create Tests:** 5. **Create Tests:**
@@ -620,6 +893,8 @@ This project is open-sourced software licensed under the [MIT license](LICENSE).
- [spatie/laravel-query-builder](https://github.com/spatie/laravel-query-builder) - Query Building - [spatie/laravel-query-builder](https://github.com/spatie/laravel-query-builder) - Query Building
- [spatie/laravel-data](https://github.com/spatie/laravel-data) - Data Transfer Objects - [spatie/laravel-data](https://github.com/spatie/laravel-data) - Data Transfer Objects
- [dedoc/scramble](https://github.com/dedoc/scramble) - API Documentation - [dedoc/scramble](https://github.com/dedoc/scramble) - API Documentation
- [grazulex/laravel-api-idempotency](https://github.com/Grazulex/laravel-api-idempotency) - API Idempotency (optional)
- [grazulex/laravel-api-throttle-smart](https://github.com/Grazulex/laravel-api-throttle-smart) - Smart Rate Limiting (optional)
- [Pest PHP](https://pestphp.com) - Testing Framework - [Pest PHP](https://pestphp.com) - Testing Framework
## Support ## Support

View File

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

View File

@@ -1,37 +1,48 @@
<?php <?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Api\ApiController; use App\Http\Controllers\Api\ApiController;
use App\Http\Requests\Api\V1\ForgotPasswordRequest;
use App\Http\Requests\Api\V1\LoginRequest; use App\Http\Requests\Api\V1\LoginRequest;
use App\Http\Requests\Api\V1\RegisterRequest; use App\Http\Requests\Api\V1\RegisterRequest;
use App\Http\Requests\Api\V1\ResendVerificationRequest;
use App\Http\Requests\Api\V1\ResetPasswordRequest;
use App\Http\Requests\Api\V1\VerifyEmailRequest;
use App\Http\Resources\UserResource; use App\Http\Resources\UserResource;
use App\Models\User; use App\Models\User;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Auth\Events\Verified;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
class AuthController extends ApiController final class AuthController extends ApiController
{ {
public function register(RegisterRequest $request): JsonResponse public function register(RegisterRequest $request): JsonResponse
{ {
$user = User::create([ $user = User::query()->create([
'name' => $request->name, 'name' => $request->name,
'email' => $request->email, 'email' => $request->email,
'password' => Hash::make($request->password), 'password' => Hash::make($request->password),
]); ]);
$user->sendEmailVerificationNotification();
$token = $user->createToken('auth-token')->plainTextToken; $token = $user->createToken('auth-token')->plainTextToken;
return $this->created([ return $this->created([
'user' => new UserResource($user), 'user' => new UserResource($user),
'token' => $token, 'token' => $token,
], 'User registered successfully'); ], 'User registered successfully. Please check your email to verify your account.');
} }
public function login(LoginRequest $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)) { if (! $user || ! Hash::check($request->password, $user->password)) {
return $this->unauthorized('Invalid credentials'); return $this->unauthorized('Invalid credentials');
@@ -47,7 +58,9 @@ class AuthController extends ApiController
public function logout(Request $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'); return $this->success(message: 'Logged out successfully');
} }
@@ -56,4 +69,79 @@ class AuthController extends ApiController
{ {
return $this->success(new UserResource($request->user())); return $this->success(new UserResource($request->user()));
} }
public function verifyEmail(VerifyEmailRequest $request): JsonResponse
{
/** @var User $user */
$user = $request->user();
if ($user->hasVerifiedEmail()) {
return $this->success(message: 'Email already verified');
}
if ($user->markEmailAsVerified()) {
event(new Verified($user));
}
return $this->success(message: 'Email verified successfully');
}
public function resendVerificationEmail(ResendVerificationRequest $request): JsonResponse
{
$user = User::query()->where('email', $request->email)->first();
if (! $user) {
return $this->notFound('User not found');
}
if ($user->hasVerifiedEmail()) {
return $this->error('Email already verified', 400);
}
$user->sendEmailVerificationNotification();
return $this->success(message: 'Verification email sent successfully');
}
public function forgotPassword(ForgotPasswordRequest $request): JsonResponse
{
$status = Password::sendResetLink(
$request->only('email')
);
if ($status === Password::RESET_LINK_SENT) {
return $this->success(message: 'Password reset link sent to your email');
}
return $this->error('Unable to send reset link', 500);
}
public function resetPassword(ResetPasswordRequest $request): JsonResponse
{
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function (User $user, string $password): void {
$user->forceFill([
'password' => Hash::make($password),
])->save();
$user->tokens()->delete();
event(new PasswordReset($user));
}
);
if ($status === Password::PASSWORD_RESET) {
return $this->success(message: 'Password reset successfully');
}
return $this->error(
match ($status) {
Password::INVALID_TOKEN => 'Invalid or expired reset token',
Password::INVALID_USER => 'User not found',
default => 'Unable to reset password',
},
400
);
}
} }

View File

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

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
final class EnsureEmailVerified
{
/**
* Ensure the user's email is verified before allowing access.
*/
public function handle(Request $request, Closure $next): mixed
{
$user = $request->user();
if (! $user) {
return response()->json([
'success' => false,
'message' => 'Unauthenticated',
], 401);
}
if (! $user->hasVerifiedEmail()) {
return response()->json([
'success' => false,
'message' => 'Your email address is not verified. Please verify your email to continue.',
], 403);
}
return $next($request);
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class ForceJsonResponse
{
/**
* Ensure all responses are JSON and set proper Accept header.
*/
public function handle(Request $request, Closure $next): mixed
{
$request->headers->set('Accept', 'application/json');
$response = $next($request);
if ($response instanceof JsonResponse) {
return $response;
}
// Convert non-JSON responses to JSON
if ($response instanceof Response) {
return response()->json([
'success' => false,
'message' => 'An error occurred',
'data' => $response->getContent(),
], $response->getStatusCode());
}
return $response;
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
final class LogApiRequests
{
/**
* Log API requests for debugging and monitoring.
*/
public function handle(Request $request, Closure $next): mixed
{
$startTime = microtime(true);
$response = $next($request);
// Ensure we have a Response object
if (! $response instanceof Response) {
return $response;
}
$duration = round((microtime(true) - $startTime) * 1000, 2);
// Only log if enabled via config
if (config('app.log_api_requests', false)) {
Log::info('API Request', [
'timestamp' => now()->toIso8601String(),
'method' => $request->method(),
'url' => $request->fullUrl(),
'ip' => $request->ip(),
'user_id' => $request->user()?->id,
'status' => $response->getStatusCode(),
'duration_ms' => $duration,
'user_agent' => $request->userAgent(),
]);
}
// Add performance header
$response->headers->set('X-Response-Time', $duration.'ms');
return $response;
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1;
use Illuminate\Foundation\Http\FormRequest;
final class ForgotPasswordRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'email' => ['required', 'email', 'exists:users,email'],
];
}
}

View File

@@ -1,10 +1,17 @@
<?php <?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1; namespace App\Http\Requests\Api\V1;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
class LoginRequest extends FormRequest /**
* @property string $email
* @property string $password
*/
final class LoginRequest extends FormRequest
{ {
public function authorize(): bool 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 public function rules(): array
{ {

View File

@@ -1,10 +1,18 @@
<?php <?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1; namespace App\Http\Requests\Api\V1;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest; 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 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 public function rules(): array
{ {

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1;
use Illuminate\Foundation\Http\FormRequest;
final class ResendVerificationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'email' => ['required', 'email', 'exists:users,email'],
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Password;
final class ResetPasswordRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'token' => ['required', 'string'],
'email' => ['required', 'email'],
'password' => ['required', 'confirmed', Password::defaults()],
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1;
use Illuminate\Foundation\Http\FormRequest;
final class VerifyEmailRequest extends FormRequest
{
public function authorize(): bool
{
// Allow if user is authenticated and the ID matches
return $this->user() && (int) $this->route('id') === $this->user()->id;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [];
}
}

View File

@@ -1,14 +1,17 @@
<?php <?php
declare(strict_types=1);
namespace App\Http\Resources; namespace App\Http\Resources;
use App\Models\User;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; 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> * @return array<string, mixed>

View File

@@ -1,38 +1,45 @@
<?php <?php
declare(strict_types=1);
namespace App\Models; namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail; use Database\Factories\UserFactory;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Carbon;
use Laravel\Sanctum\HasApiTokens; 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
*/
#[Fillable([
'name',
'email',
'password',
])]
#[Hidden([
'password',
'remember_token',
])]
final class User extends Authenticatable implements MustVerifyEmail
{ {
/** @use HasFactory<\Database\Factories\UserFactory> */ use HasApiTokens;
use HasApiTokens, HasFactory, Notifiable;
/** /** @use HasFactory<UserFactory> */
* The attributes that are mass assignable. use HasFactory;
*
* @var list<string>
*/
protected $fillable = [
'name',
'email',
'password',
];
/** use Notifiable;
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'password',
'remember_token',
];
/** /**
* Get the attributes that should be cast. * Get the attributes that should be cast.

View File

@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace App\Providers; namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Cache\RateLimiting\Limit;
@@ -7,7 +9,7 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider final class AppServiceProvider extends ServiceProvider
{ {
/** /**
* Register any application services. * Register any application services.
@@ -28,23 +30,17 @@ class AppServiceProvider extends ServiceProvider
/** /**
* Configure the rate limiters for the application. * Configure the rate limiters for the application.
*/ */
protected function configureRateLimiting(): void private function configureRateLimiting(): void
{ {
// Default API rate limiter - 60 requests per minute // Default API rate limiter - 60 requests per minute
RateLimiter::for('api', function (Request $request) { RateLimiter::for('api', fn (Request $request) => Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()));
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
// Auth endpoints - more restrictive (prevent brute force) // Auth endpoints - more restrictive (prevent brute force)
RateLimiter::for('auth', function (Request $request) { RateLimiter::for('auth', fn (Request $request) => Limit::perMinute(5)->by($request->ip()));
return Limit::perMinute(5)->by($request->ip());
});
// Authenticated user requests - higher limit // Authenticated user requests - higher limit
RateLimiter::for('authenticated', function (Request $request) { RateLimiter::for('authenticated', fn (Request $request) => $request->user()
return $request->user() ? Limit::perMinute(120)->by($request->user()->id)
? Limit::perMinute(120)->by($request->user()->id) : Limit::perMinute(60)->by($request->ip()));
: Limit::perMinute(60)->by($request->ip());
});
} }
} }

View File

@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace App\Traits; namespace App\Traits;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -31,6 +33,9 @@ trait ApiResponse
return response()->json(null, Response::HTTP_NO_CONTENT); return response()->json(null, Response::HTTP_NO_CONTENT);
} }
/**
* @param array<string, mixed> $errors
*/
protected function error( protected function error(
string $message = 'Error', string $message = 'Error',
int $code = Response::HTTP_BAD_REQUEST, int $code = Response::HTTP_BAD_REQUEST,
@@ -41,7 +46,7 @@ trait ApiResponse
'message' => $message, 'message' => $message,
]; ];
if (! empty($errors)) { if ($errors !== []) {
$response['errors'] = $errors; $response['errors'] = $errors;
} }
@@ -63,6 +68,9 @@ trait ApiResponse
return $this->error($message, Response::HTTP_FORBIDDEN); return $this->error($message, Response::HTTP_FORBIDDEN);
} }
/**
* @param array<string, mixed> $errors
*/
protected function validationError(array $errors, string $message = 'Validation failed'): JsonResponse protected function validationError(array $errors, string $message = 'Validation failed'): JsonResponse
{ {
return $this->error($message, Response::HTTP_UNPROCESSABLE_ENTITY, $errors); return $this->error($message, Response::HTTP_UNPROCESSABLE_ENTITY, $errors);

View File

@@ -1,5 +1,10 @@
<?php <?php
declare(strict_types=1);
use App\Http\Middleware\EnsureEmailVerified;
use App\Http\Middleware\ForceJsonResponse;
use App\Http\Middleware\LogApiRequests;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
@@ -12,7 +17,11 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
// $middleware->alias([
'force.json' => ForceJsonResponse::class,
'log.api' => LogApiRequests::class,
'verified' => EnsureEmailVerified::class,
]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
// //

View File

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

View File

@@ -6,23 +6,30 @@
"keywords": ["laravel", "api", "rest", "starter-kit", "sanctum"], "keywords": ["laravel", "api", "rest", "starter-kit", "sanctum"],
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.2", "php": "^8.3",
"dedoc/scramble": "^0.12", "dedoc/scramble": "^0.12|^0.13",
"grazulex/laravel-apiroute": "^1.2", "grazulex/laravel-apiroute": "^2.0",
"laravel/framework": "^12.0", "laravel/framework": "^13.0",
"laravel/sanctum": "^4.0", "laravel/sanctum": "^4.0",
"laravel/tinker": "^2.10.1", "laravel/tinker": "^3.0",
"spatie/laravel-data": "^4.0", "spatie/laravel-data": "^4.0",
"spatie/laravel-query-builder": "^6.0" "spatie/laravel-query-builder": "^7.0"
}, },
"require-dev": { "require-dev": {
"driftingly/rector-laravel": "^2.0",
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",
"larastan/larastan": "^3.7",
"laravel/pail": "^1.2.2", "laravel/pail": "^1.2.2",
"laravel/pint": "^1.24", "laravel/pint": "^1.24",
"mockery/mockery": "^1.6", "mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6", "nunomaduro/collision": "^8.6",
"pestphp/pest": "^3.0", "pestphp/pest": "^4.0",
"pestphp/pest-plugin-laravel": "^3.0" "pestphp/pest-plugin-laravel": "^4.0",
"rector/rector": "^2.2"
},
"suggest": {
"grazulex/laravel-api-idempotency": "RFC-compliant API idempotency with response caching and multiple storage drivers",
"grazulex/laravel-api-throttle-smart": "Plan-aware smart rate limiting with quotas, multiple algorithms and multi-tenant support"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
@@ -37,10 +44,26 @@
} }
}, },
"scripts": { "scripts": {
"test": [ "lint": [
"rector",
"pint"
],
"test:lint": [
"pint --test",
"rector --dry-run"
],
"test:types": [
"phpstan"
],
"test:unit": [
"@php artisan config:clear --ansi", "@php artisan config:clear --ansi",
"@php artisan test" "@php artisan test"
], ],
"test": [
"@test:lint",
"@test:types",
"@test:unit"
],
"post-autoload-dump": [ "post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi" "@php artisan package:discover --ansi"
@@ -69,6 +92,9 @@
"allow-plugins": { "allow-plugins": {
"pestphp/pest-plugin": true, "pestphp/pest-plugin": true,
"php-http/discovery": true "php-http/discovery": true
},
"platform": {
"php": "8.3.0"
} }
}, },
"minimum-stability": "stable", "minimum-stability": "stable",

2013
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,28 @@
declare(strict_types=1); declare(strict_types=1);
return [ 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 | Detection Strategy
@@ -21,7 +43,7 @@ return [
*/ */
'strategies' => [ 'strategies' => [
'uri' => [ '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. 'pattern' => 'v{version}', // v1, v2, etc.
], ],
'header' => [ 'header' => [

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
use Illuminate\Support\Str; use Illuminate\Support\Str;
return [ return [
@@ -59,7 +61,7 @@ return [
'strict' => true, 'strict' => true,
'engine' => null, 'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([ '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, 'strict' => true,
'engine' => null, 'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([ '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 <?php
declare(strict_types=1);
return [ return [
/* /*

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,10 @@
<?php <?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; use Laravel\Sanctum\Sanctum;
return [ return [
@@ -15,7 +20,7 @@ return [
| |
*/ */
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( 'stateful' => explode(',', (string) env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s', '%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
Sanctum::currentApplicationUrlWithPort(), Sanctum::currentApplicationUrlWithPort(),
@@ -76,9 +81,9 @@ return [
*/ */
'middleware' => [ 'middleware' => [
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, 'authenticate_session' => AuthenticateSession::class,
'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, 'encrypt_cookies' => EncryptCookies::class,
'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, 'validate_csrf_token' => ValidateCsrfToken::class,
], ],
]; ];

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,23 @@
<?php <?php
declare(strict_types=1);
namespace Database\Factories; namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str; 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. * The current password being used by the factory.
*/ */
protected static ?string $password; private static ?string $password = null;
/** /**
* Define the model's default state. * Define the model's default state.
@@ -27,7 +30,7 @@ class UserFactory extends Factory
'name' => fake()->name(), 'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(), 'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(), 'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'), 'password' => self::$password ??= Hash::make('password'),
'remember_token' => Str::random(10), 'remember_token' => Str::random(10),
]; ];
} }
@@ -37,7 +40,7 @@ class UserFactory extends Factory
*/ */
public function unverified(): static public function unverified(): static
{ {
return $this->state(fn (array $attributes) => [ return $this->state(fn (array $attributes): array => [
'email_verified_at' => null, 'email_verified_at' => null,
]); ]);
} }

View File

@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
@@ -11,7 +13,7 @@ return new class extends Migration
*/ */
public function up(): void public function up(): void
{ {
Schema::create('users', function (Blueprint $table) { Schema::create('users', function (Blueprint $table): void {
$table->id(); $table->id();
$table->string('name'); $table->string('name');
$table->string('email')->unique(); $table->string('email')->unique();
@@ -21,13 +23,13 @@ return new class extends Migration
$table->timestamps(); $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('email')->primary();
$table->string('token'); $table->string('token');
$table->timestamp('created_at')->nullable(); $table->timestamp('created_at')->nullable();
}); });
Schema::create('sessions', function (Blueprint $table) { Schema::create('sessions', function (Blueprint $table): void {
$table->string('id')->primary(); $table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index(); $table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable(); $table->string('ip_address', 45)->nullable();

View File

@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
@@ -11,13 +13,13 @@ return new class extends Migration
*/ */
public function up(): void public function up(): void
{ {
Schema::create('cache', function (Blueprint $table) { Schema::create('cache', function (Blueprint $table): void {
$table->string('key')->primary(); $table->string('key')->primary();
$table->mediumText('value'); $table->mediumText('value');
$table->integer('expiration'); $table->integer('expiration');
}); });
Schema::create('cache_locks', function (Blueprint $table) { Schema::create('cache_locks', function (Blueprint $table): void {
$table->string('key')->primary(); $table->string('key')->primary();
$table->string('owner'); $table->string('owner');
$table->integer('expiration'); $table->integer('expiration');

View File

@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
@@ -11,7 +13,7 @@ return new class extends Migration
*/ */
public function up(): void public function up(): void
{ {
Schema::create('jobs', function (Blueprint $table) { Schema::create('jobs', function (Blueprint $table): void {
$table->id(); $table->id();
$table->string('queue')->index(); $table->string('queue')->index();
$table->longText('payload'); $table->longText('payload');
@@ -21,7 +23,7 @@ return new class extends Migration
$table->unsignedInteger('created_at'); $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('id')->primary();
$table->string('name'); $table->string('name');
$table->integer('total_jobs'); $table->integer('total_jobs');
@@ -34,7 +36,7 @@ return new class extends Migration
$table->integer('finished_at')->nullable(); $table->integer('finished_at')->nullable();
}); });
Schema::create('failed_jobs', function (Blueprint $table) { Schema::create('failed_jobs', function (Blueprint $table): void {
$table->id(); $table->id();
$table->string('uuid')->unique(); $table->string('uuid')->unique();
$table->text('connection'); $table->text('connection');

View File

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

View File

@@ -1,12 +1,14 @@
<?php <?php
declare(strict_types=1);
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder final class DatabaseSeeder extends Seeder
{ {
use WithoutModelEvents; 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 <?php
declare(strict_types=1);
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Http\Request; use Illuminate\Http\Request;

55
rector.php Normal file
View File

@@ -0,0 +1,55 @@
<?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,
LaravelSetList::LARAVEL_130,
])
->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 <?php
use App\Http\Controllers\Api\V1\AuthController; declare(strict_types=1);
use Grazulex\ApiRoute\Facades\ApiRoute;
use Illuminate\Support\Facades\Route;
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| API Routes | 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. | Supports URI path, header, query, and Accept header detection.
| See config/apiroute.php for configuration options. | See config/apiroute.php for configuration options.
| |
*/ */
// Version 1 - Current stable version // Routes are now loaded automatically from config/apiroute.php
ApiRoute::version('v1', function () { // See routes/api/v1.php for version 1 routes
// 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();

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

@@ -0,0 +1,43 @@
<?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');
// Email verification
Route::post('email/verify/{id}/{hash}', [AuthController::class, 'verifyEmail'])
->middleware('signed')
->name('verification.verify');
Route::post('email/resend', [AuthController::class, 'resendVerificationEmail'])
->middleware('throttle:6,1')
->name('verification.send');
});
// Password reset routes (public with rate limiting)
Route::middleware('throttle:6,1')->group(function (): void {
Route::post('forgot-password', [AuthController::class, 'forgotPassword'])
->name('password.email');
Route::post('reset-password', [AuthController::class, 'resetPassword'])
->name('password.reset');
});

View File

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

View File

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

View File

@@ -1,12 +1,14 @@
<?php <?php
declare(strict_types=1);
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
describe('Registration', function () { describe('Registration', function (): void {
it('registers a new user successfully', function () { it('registers a new user successfully', function (): void {
$response = $this->postJson('/api/v1/register', [ $response = $this->postJson('/api/v1/register', [
'name' => 'Test User', 'name' => 'Test User',
'email' => 'test@example.com', 'email' => 'test@example.com',
@@ -25,7 +27,7 @@ describe('Registration', function () {
]) ])
->assertJson([ ->assertJson([
'success' => true, 'success' => true,
'message' => 'User registered successfully', 'message' => 'User registered successfully. Please check your email to verify your account.',
]); ]);
$this->assertDatabaseHas('users', [ $this->assertDatabaseHas('users', [
@@ -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', [ $response = $this->postJson('/api/v1/register', [
'name' => '', 'name' => '',
'email' => 'invalid-email', 'email' => 'invalid-email',
@@ -43,7 +45,7 @@ describe('Registration', function () {
$response->assertStatus(422); $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']); User::factory()->create(['email' => 'existing@example.com']);
$response = $this->postJson('/api/v1/register', [ $response = $this->postJson('/api/v1/register', [
@@ -57,8 +59,8 @@ describe('Registration', function () {
}); });
}); });
describe('Login', function () { describe('Login', function (): void {
it('logs in with valid credentials', function () { it('logs in with valid credentials', function (): void {
$user = User::factory()->create([ $user = User::factory()->create([
'password' => bcrypt('password123'), '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([ $user = User::factory()->create([
'password' => bcrypt('password123'), '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', [ $response = $this->postJson('/api/v1/login', [
'email' => 'nonexistent@example.com', 'email' => 'nonexistent@example.com',
'password' => 'password123', 'password' => 'password123',
@@ -110,12 +112,12 @@ describe('Login', function () {
}); });
}); });
describe('Logout', function () { describe('Logout', function (): void {
it('logs out authenticated user', function () { it('logs out authenticated user', function (): void {
$user = User::factory()->create(); $user = User::factory()->create();
$token = $user->createToken('test-token')->plainTextToken; $token = $user->createToken('test-token')->plainTextToken;
$response = $this->withHeader('Authorization', "Bearer {$token}") $response = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/logout'); ->postJson('/api/v1/logout');
$response->assertStatus(200) $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 = $this->postJson('/api/v1/logout');
$response->assertStatus(401); $response->assertStatus(401);
}); });
}); });
describe('Me', function () { describe('Me', function (): void {
it('returns authenticated user data', function () { it('returns authenticated user data', function (): void {
$user = User::factory()->create(); $user = User::factory()->create();
$token = $user->createToken('test-token')->plainTextToken; $token = $user->createToken('test-token')->plainTextToken;
$response = $this->withHeader('Authorization', "Bearer {$token}") $response = $this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/me'); ->getJson('/api/v1/me');
$response->assertStatus(200) $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 = $this->getJson('/api/v1/me');
$response->assertStatus(401); $response->assertStatus(401);

View File

@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
use App\Models\User;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\URL;
uses(RefreshDatabase::class);
describe('Email Verification', function (): void {
it('verifies email successfully with valid link', function (): void {
Event::fake();
$user = User::factory()->create(['email_verified_at' => null]);
$token = $user->createToken('test-token')->plainTextToken;
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1($user->email)]
);
$response = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson($verificationUrl);
$response->assertStatus(200)
->assertJson([
'success' => true,
'message' => 'Email verified successfully',
]);
$this->assertNotNull($user->fresh()->email_verified_at);
Event::assertDispatched(Verified::class);
});
it('returns success if email is already verified', function (): void {
$user = User::factory()->create([
'email_verified_at' => now(),
]);
$token = $user->createToken('test-token')->plainTextToken;
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1($user->email)]
);
$response = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson($verificationUrl);
$response->assertStatus(200)
->assertJson([
'success' => true,
'message' => 'Email already verified',
]);
});
it('fails verification without authentication', function (): void {
$user = User::factory()->create(['email_verified_at' => null]);
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1($user->email)]
);
$response = $this->postJson($verificationUrl);
$response->assertStatus(401);
});
it('fails verification with invalid signature', function (): void {
$user = User::factory()->create(['email_verified_at' => null]);
$token = $user->createToken('test-token')->plainTextToken;
// Invalid URL without signature
$response = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson(sprintf('/api/v1/email/verify/%d/invalid-hash', $user->id));
$response->assertStatus(403);
});
});
describe('Resend Verification Email', function (): void {
it('resends verification email successfully', function (): void {
$user = User::factory()->create(['email_verified_at' => null]);
$token = $user->createToken('test-token')->plainTextToken;
$response = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/email/resend', [
'email' => $user->email,
]);
$response->assertStatus(200)
->assertJson([
'success' => true,
'message' => 'Verification email sent successfully',
]);
});
it('fails to resend if email is already verified', function (): void {
$user = User::factory()->create([
'email_verified_at' => now(),
]);
$token = $user->createToken('test-token')->plainTextToken;
$response = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/email/resend', [
'email' => $user->email,
]);
$response->assertStatus(400)
->assertJson([
'success' => false,
'message' => 'Email already verified',
]);
});
it('fails with invalid email', function (): void {
$user = User::factory()->create();
$token = $user->createToken('test-token')->plainTextToken;
$response = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/email/resend', [
'email' => 'nonexistent@example.com',
]);
$response->assertStatus(422);
});
it('requires authentication', function (): void {
$user = User::factory()->create(['email_verified_at' => null]);
$response = $this->postJson('/api/v1/email/resend', [
'email' => $user->email,
]);
$response->assertStatus(401);
});
it('respects rate limiting', function (): void {
$user = User::factory()->create(['email_verified_at' => null]);
$token = $user->createToken('test-token')->plainTextToken;
// Make 7 requests (limit is 6 per minute)
for ($i = 0; $i < 7; $i++) {
$response = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/email/resend', [
'email' => $user->email,
]);
}
// Last request should be rate limited
$response->assertStatus(429);
});
});

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
uses(RefreshDatabase::class);
describe('Forgot Password', function (): void {
it('sends reset link successfully', function (): void {
$user = User::factory()->create();
$response = $this->postJson('/api/v1/forgot-password', [
'email' => $user->email,
]);
$response->assertStatus(200)
->assertJson([
'success' => true,
'message' => 'Password reset link sent to your email',
]);
});
it('fails with non-existent email', function (): void {
$response = $this->postJson('/api/v1/forgot-password', [
'email' => 'nonexistent@example.com',
]);
$response->assertStatus(422);
});
it('respects rate limiting', function (): void {
$user = User::factory()->create();
// Make 7 requests (limit is 6 per minute)
for ($i = 0; $i < 7; $i++) {
$response = $this->postJson('/api/v1/forgot-password', [
'email' => $user->email,
]);
}
// Last request should be rate limited
$response->assertStatus(429);
});
});
describe('Reset Password', function (): void {
it('resets password successfully with valid token', function (): void {
$user = User::factory()->create();
$token = Password::createToken($user);
$response = $this->postJson('/api/v1/reset-password', [
'email' => $user->email,
'token' => $token,
'password' => 'newpassword123',
'password_confirmation' => 'newpassword123',
]);
$response->assertStatus(200)
->assertJson([
'success' => true,
'message' => 'Password reset successfully',
]);
// Verify password was changed
$this->assertTrue(Hash::check('newpassword123', $user->fresh()->password));
// Verify all tokens were deleted
$this->assertDatabaseCount('personal_access_tokens', 0);
});
it('fails with invalid token', function (): void {
$user = User::factory()->create();
$response = $this->postJson('/api/v1/reset-password', [
'email' => $user->email,
'token' => 'invalid-token',
'password' => 'newpassword123',
'password_confirmation' => 'newpassword123',
]);
$response->assertStatus(400)
->assertJson([
'success' => false,
'message' => 'Invalid or expired reset token',
]);
});
it('fails with mismatched passwords', function (): void {
$user = User::factory()->create();
$token = Password::createToken($user);
$response = $this->postJson('/api/v1/reset-password', [
'email' => $user->email,
'token' => $token,
'password' => 'newpassword123',
'password_confirmation' => 'differentpassword',
]);
$response->assertStatus(422);
});
it('fails with non-existent email', function (): void {
$response = $this->postJson('/api/v1/reset-password', [
'email' => 'nonexistent@example.com',
'token' => 'some-token',
'password' => 'newpassword123',
'password_confirmation' => 'newpassword123',
]);
$response->assertStatus(400)
->assertJson([
'success' => false,
'message' => 'User not found',
]);
});
});

View File

@@ -1,5 +1,9 @@
<?php <?php
declare(strict_types=1);
use Tests\TestCase;
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Test Case | Test Case
@@ -11,7 +15,7 @@
| |
*/ */
pest()->extend(Tests\TestCase::class) pest()->extend(TestCase::class)
// ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) // ->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
->in('Feature'); ->in('Feature');
@@ -26,9 +30,7 @@ pest()->extend(Tests\TestCase::class)
| |
*/ */
expect()->extend('toBeOne', function () { expect()->extend('toBeOne', fn () => $this->toBe(1));
return $this->toBe(1);
});
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@@ -41,7 +43,7 @@ expect()->extend('toBeOne', function () {
| |
*/ */
function something() function something(): void
{ {
// .. // ..
} }

View File

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