Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c1f450c4b |
4
.github/FUNDING.yml
vendored
4
.github/FUNDING.yml
vendored
@@ -1,4 +0,0 @@
|
||||
# These are supported funding model platforms
|
||||
github: Grazulex
|
||||
buy_me_a_coffee: Grazulex
|
||||
custom: ["https://paypal.me/strauven"]
|
||||
6
.github/workflows/tests.yml
vendored
6
.github/workflows/tests.yml
vendored
@@ -12,10 +12,6 @@ jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.3', '8.4']
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -23,7 +19,7 @@ jobs:
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
php-version: 8.3
|
||||
tools: composer:v2
|
||||
coverage: xdebug
|
||||
|
||||
|
||||
20
CHANGELOG.md
20
CHANGELOG.md
@@ -2,27 +2,11 @@
|
||||
|
||||
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)
|
||||
## [2.1.0](https://github.com/Grazulex/laravel-api-kit/releases/tag/v2.1.0) (2026-01-20)
|
||||
|
||||
### 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)
|
||||
- integrate PHPStan, Rector and Pint code quality tools (#13) ([f417ec2](https://github.com/Grazulex/laravel-api-kit/commit/f417ec23c2eada4eaddf3ec9e4cff7ccc71a3572))
|
||||
## [2.0.2](https://github.com/Grazulex/laravel-api-kit/releases/tag/v2.0.2) (2026-01-08)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
236
README.md
236
README.md
@@ -1,17 +1,15 @@
|
||||
# Laravel API Kit
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
[](https://php.net)
|
||||
[](https://laravel.com)
|
||||
[](https://laravel.com)
|
||||
[](LICENSE)
|
||||
|
||||
## Features
|
||||
|
||||
- **API-Only** - No Blade, Vite, or frontend assets
|
||||
- **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)
|
||||
- **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)
|
||||
@@ -19,10 +17,7 @@ A production-ready, API-only Laravel 13 starter kit following the 2025-2026 REST
|
||||
- **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
|
||||
- **Reusable Middleware** - ForceJsonResponse, LogApiRequests, EnsureEmailVerified
|
||||
- **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
|
||||
|
||||
@@ -107,13 +102,12 @@ curl -X POST http://localhost:8080/api/v1/register \
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "User registered successfully. Please check your email to verify your account.",
|
||||
"message": "User registered successfully",
|
||||
"data": {
|
||||
"user": {
|
||||
"id": 1,
|
||||
"name": "John Doe",
|
||||
"email": "john@example.com",
|
||||
"email_verified_at": null,
|
||||
"created_at": "2025-01-15T10:30:00+00:00"
|
||||
},
|
||||
"token": "1|abc123..."
|
||||
@@ -151,92 +145,16 @@ curl -X POST http://localhost:8080/api/v1/logout \
|
||||
-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
|
||||
|
||||
### Version 1 (`/api/v1`)
|
||||
|
||||
| Method | Endpoint | Auth | Description | Rate Limit |
|
||||
|--------|------------------------------|------|-------------------------------|------------|
|
||||
| POST | /register | No | Register new user | 5/min |
|
||||
| POST | /login | No | Get authentication token | 5/min |
|
||||
| POST | /logout | Yes | Revoke current token | 120/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 |
|
||||
| Method | Endpoint | Auth | Description | Rate Limit |
|
||||
|--------|-------------|------|--------------------------|------------|
|
||||
| POST | /register | No | Register new user | 5/min |
|
||||
| POST | /login | No | Get authentication token | 5/min |
|
||||
| POST | /logout | Yes | Revoke current token | 60/min |
|
||||
| GET | /me | Yes | Get current user profile | 60/min |
|
||||
|
||||
## Response Format
|
||||
|
||||
@@ -467,140 +385,6 @@ X-RateLimit-Remaining: 59
|
||||
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
|
||||
|
||||
This kit uses [Pest PHP](https://pestphp.com/) for testing:
|
||||
@@ -893,8 +677,6 @@ 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-data](https://github.com/spatie/laravel-data) - Data Transfer Objects
|
||||
- [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
|
||||
|
||||
## Support
|
||||
|
||||
@@ -5,20 +5,13 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
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\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\Models\User;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
|
||||
final class AuthController extends ApiController
|
||||
{
|
||||
@@ -30,14 +23,12 @@ final class AuthController extends ApiController
|
||||
'password' => Hash::make($request->password),
|
||||
]);
|
||||
|
||||
$user->sendEmailVerificationNotification();
|
||||
|
||||
$token = $user->createToken('auth-token')->plainTextToken;
|
||||
|
||||
return $this->created([
|
||||
'user' => new UserResource($user),
|
||||
'token' => $token,
|
||||
], 'User registered successfully. Please check your email to verify your account.');
|
||||
], 'User registered successfully');
|
||||
}
|
||||
|
||||
public function login(LoginRequest $request): JsonResponse
|
||||
@@ -69,79 +60,4 @@ final class AuthController extends ApiController
|
||||
{
|
||||
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,35 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?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,25 +0,0 @@
|
||||
<?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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<?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()],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?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 [];
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
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\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
@@ -23,16 +21,7 @@ use Laravel\Sanctum\HasApiTokens;
|
||||
* @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
|
||||
final class User extends Authenticatable
|
||||
{
|
||||
use HasApiTokens;
|
||||
|
||||
@@ -41,6 +30,27 @@ final class User extends Authenticatable implements MustVerifyEmail
|
||||
|
||||
use Notifiable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
|
||||
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\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
@@ -17,11 +14,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->alias([
|
||||
'force.json' => ForceJsonResponse::class,
|
||||
'log.api' => LogApiRequests::class,
|
||||
'verified' => EnsureEmailVerified::class,
|
||||
]);
|
||||
//
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"dedoc/scramble": "^0.12|^0.13",
|
||||
"dedoc/scramble": "^0.12",
|
||||
"grazulex/laravel-apiroute": "^2.0",
|
||||
"laravel/framework": "^13.0",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/sanctum": "^4.0",
|
||||
"laravel/tinker": "^3.0",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"spatie/laravel-data": "^4.0",
|
||||
"spatie/laravel-query-builder": "^7.0"
|
||||
"spatie/laravel-query-builder": "^6.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"driftingly/rector-laravel": "^2.0",
|
||||
@@ -27,10 +27,6 @@
|
||||
"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": {
|
||||
"psr-4": {
|
||||
"App\\": "app/",
|
||||
@@ -92,9 +88,6 @@
|
||||
"allow-plugins": {
|
||||
"pestphp/pest-plugin": true,
|
||||
"php-http/discovery": true
|
||||
},
|
||||
"platform": {
|
||||
"php": "8.3.0"
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
|
||||
1111
composer.lock
generated
1111
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,6 @@ return RectorConfig::configure()
|
||||
LaravelSetList::LARAVEL_FACTORIES,
|
||||
LaravelSetList::LARAVEL_IF_HELPERS,
|
||||
LaravelSetList::LARAVEL_LEGACY_FACTORIES_TO_CLASSES,
|
||||
LaravelSetList::LARAVEL_130,
|
||||
])
|
||||
->withImportNames(
|
||||
removeUnusedImports: true,
|
||||
|
||||
@@ -24,20 +24,4 @@ Route::middleware('throttle:auth')->group(function (): void {
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ describe('Registration', function (): void {
|
||||
])
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'message' => 'User registered successfully. Please check your email to verify your account.',
|
||||
'message' => 'User registered successfully',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('users', [
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
<?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);
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
<?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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user