Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98b8ff236b | ||
|
|
53913c1596 | ||
|
|
f2dcd0059a | ||
|
|
c72b740749 | ||
|
|
2791415401 | ||
|
|
ada996848a | ||
|
|
43a36e4913 | ||
|
|
8b0b57808b | ||
|
|
5cad524908 | ||
|
|
84a094666d | ||
|
|
5250000618 | ||
|
|
b62ec456ad | ||
|
|
03de471bb5 | ||
|
|
b14f4f2dd9 | ||
|
|
13da9b84c1 | ||
|
|
07c36c0009 | ||
|
|
f417ec23c2 | ||
|
|
d961b44c30 | ||
|
|
2b0f6cedaa | ||
|
|
bbc34935c6 | ||
|
|
fcf6483127 | ||
|
|
6c24d290f4 | ||
|
|
396207d6b0 | ||
|
|
7a35de80d0 | ||
|
|
9f0d41d827 | ||
|
|
562448f576 | ||
|
|
cb911a3c04 | ||
|
|
06ee9a5016 | ||
|
|
0113b0112b | ||
|
|
ae8d4bd432 | ||
|
|
c9fbf64472 | ||
|
|
d93b82b1eb | ||
|
|
02c55f765f |
4
.github/FUNDING.yml
vendored
Normal file
4
.github/FUNDING.yml
vendored
Normal 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
59
.github/workflows/tests.yml
vendored
Normal 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
|
||||||
35
CHANGELOG.md
35
CHANGELOG.md
@@ -2,6 +2,41 @@
|
|||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
### 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)
|
## [1.0.0](https://github.com/Grazulex/laravel-api-kit/releases/tag/v1.0.0) (2025-12-25)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
351
README.md
351
README.md
@@ -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.
|
||||||
|
|
||||||
[](https://php.net)
|
[](https://php.net)
|
||||||
[](https://laravel.com)
|
[](https://laravel.com)
|
||||||
[](LICENSE)
|
[](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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
abstract class Controller
|
abstract class Controller
|
||||||
|
|||||||
35
app/Http/Middleware/EnsureEmailVerified.php
Normal file
35
app/Http/Middleware/EnsureEmailVerified.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/Http/Middleware/ForceJsonResponse.php
Normal file
38
app/Http/Middleware/ForceJsonResponse.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Http/Middleware/LogApiRequests.php
Normal file
49
app/Http/Middleware/LogApiRequests.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Http/Requests/Api/V1/ForgotPasswordRequest.php
Normal file
25
app/Http/Requests/Api/V1/ForgotPasswordRequest.php
Normal 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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
25
app/Http/Requests/Api/V1/ResendVerificationRequest.php
Normal file
25
app/Http/Requests/Api/V1/ResendVerificationRequest.php
Normal 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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Http/Requests/Api/V1/ResetPasswordRequest.php
Normal file
28
app/Http/Requests/Api/V1/ResetPasswordRequest.php
Normal 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()],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/Http/Requests/Api/V1/VerifyEmailRequest.php
Normal file
24
app/Http/Requests/Api/V1/VerifyEmailRequest.php
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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());
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
App\Providers\AppServiceProvider::class,
|
App\Providers\AppServiceProvider::class,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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": "^0.0",
|
"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
2013
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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' => [
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -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' => [
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -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'),
|
||||||
]) : [],
|
]) : [],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -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,
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
use Dedoc\Scramble\Http\Middleware\RestrictedDocsAccess;
|
use Dedoc\Scramble\Http\Middleware\RestrictedDocsAccess;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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
21
phpstan.neon
Normal 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
63
pint.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
55
rector.php
Normal 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();
|
||||||
@@ -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
43
routes/api/v1.php
Normal 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');
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
159
tests/Feature/Api/V1/EmailVerificationTest.php
Normal file
159
tests/Feature/Api/V1/EmailVerificationTest.php
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
121
tests/Feature/Api/V1/PasswordResetTest.php
Normal file
121
tests/Feature/Api/V1/PasswordResetTest.php
Normal 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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
{
|
{
|
||||||
// ..
|
// ..
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user