787 lines
20 KiB
Plaintext
787 lines
20 KiB
Plaintext
---
|
|
description: Laravel API development guidelines
|
|
globs: ["api/**/*.php"]
|
|
alwaysApply: true
|
|
---
|
|
|
|
# Laravel API Development Rules
|
|
|
|
## PHP Conventions
|
|
|
|
- Use PHP 8.3+ features: constructor property promotion, readonly properties, match expressions
|
|
- Use `match` operator over `switch` wherever possible
|
|
- Import all classes with `use` statements; avoid fully-qualified class names inline
|
|
- Use named arguments for functions with 3+ parameters
|
|
- Prefer early returns over nested conditionals
|
|
|
|
```php
|
|
// ✅ Good - constructor property promotion
|
|
public function __construct(
|
|
private readonly UserRepository $users,
|
|
private readonly Mailer $mailer,
|
|
) {}
|
|
|
|
// ✅ Good - early return
|
|
public function handle(Request $request): Response
|
|
{
|
|
if (!$request->user()) {
|
|
return response()->json(['error' => 'Unauthorized'], 401);
|
|
}
|
|
|
|
// Main logic here
|
|
}
|
|
|
|
// ❌ Avoid - nested conditionals
|
|
public function handle(Request $request): Response
|
|
{
|
|
if ($request->user()) {
|
|
// Nested logic
|
|
} else {
|
|
return response()->json(['error' => 'Unauthorized'], 401);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Core Principles
|
|
|
|
1. **API-only** - No Blade views, no web routes
|
|
2. **Thin controllers** - Business logic in Actions
|
|
3. **Consistent responses** - Use API Resources and response trait
|
|
4. **Validate everything** - Use Form Requests
|
|
5. **Authorize properly** - Use Policies
|
|
6. **Test thoroughly** - Feature tests for all endpoints
|
|
|
|
## File Templates
|
|
|
|
### Model
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Models;
|
|
|
|
use App\Enums\EventStatus;
|
|
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
|
|
final class Event extends Model
|
|
{
|
|
use HasFactory;
|
|
use HasUlids;
|
|
|
|
protected $fillable = [
|
|
'title',
|
|
'description',
|
|
'location_id',
|
|
'customer_id',
|
|
'setlist_id',
|
|
'event_date',
|
|
'start_time',
|
|
'end_time',
|
|
'fee',
|
|
'currency',
|
|
'status',
|
|
'visibility',
|
|
'rsvp_deadline',
|
|
'notes',
|
|
'internal_notes',
|
|
'created_by',
|
|
];
|
|
|
|
protected $casts = [
|
|
'event_date' => 'date',
|
|
'start_time' => 'datetime:H:i',
|
|
'end_time' => 'datetime:H:i',
|
|
'fee' => 'decimal:2',
|
|
'status' => EventStatus::class,
|
|
'rsvp_deadline' => 'datetime',
|
|
];
|
|
|
|
// Relationships
|
|
|
|
public function location(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Location::class);
|
|
}
|
|
|
|
public function customer(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Customer::class);
|
|
}
|
|
|
|
public function setlist(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Setlist::class);
|
|
}
|
|
|
|
public function creator(): BelongsTo
|
|
{
|
|
return $this->belongsTo(User::class, 'created_by');
|
|
}
|
|
|
|
public function invitations(): HasMany
|
|
{
|
|
return $this->hasMany(EventInvitation::class);
|
|
}
|
|
|
|
// Scopes
|
|
|
|
public function scopeUpcoming($query)
|
|
{
|
|
return $query->where('event_date', '>=', now()->toDateString())
|
|
->orderBy('event_date');
|
|
}
|
|
|
|
public function scopeConfirmed($query)
|
|
{
|
|
return $query->where('status', EventStatus::Confirmed);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Enum
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Enums;
|
|
|
|
enum EventStatus: string
|
|
{
|
|
case Draft = 'draft';
|
|
case Pending = 'pending';
|
|
case Confirmed = 'confirmed';
|
|
case Completed = 'completed';
|
|
case Cancelled = 'cancelled';
|
|
|
|
public function label(): string
|
|
{
|
|
return match ($this) {
|
|
self::Draft => 'Draft',
|
|
self::Pending => 'Pending Confirmation',
|
|
self::Confirmed => 'Confirmed',
|
|
self::Completed => 'Completed',
|
|
self::Cancelled => 'Cancelled',
|
|
};
|
|
}
|
|
|
|
public function color(): string
|
|
{
|
|
return match ($this) {
|
|
self::Draft => 'gray',
|
|
self::Pending => 'yellow',
|
|
self::Confirmed => 'green',
|
|
self::Completed => 'blue',
|
|
self::Cancelled => 'red',
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
### Migration
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use Illuminate\Database\Migrations\Migration;
|
|
use Illuminate\Database\Schema\Blueprint;
|
|
use Illuminate\Support\Facades\Schema;
|
|
|
|
return new class extends Migration
|
|
{
|
|
public function up(): void
|
|
{
|
|
Schema::create('events', function (Blueprint $table) {
|
|
$table->ulid('id')->primary();
|
|
$table->string('title');
|
|
$table->text('description')->nullable();
|
|
$table->foreignUlid('location_id')->nullable()->constrained()->nullOnDelete();
|
|
$table->foreignUlid('customer_id')->nullable()->constrained()->nullOnDelete();
|
|
$table->foreignUlid('setlist_id')->nullable()->constrained()->nullOnDelete();
|
|
$table->date('event_date');
|
|
$table->time('start_time');
|
|
$table->time('end_time')->nullable();
|
|
$table->time('load_in_time')->nullable();
|
|
$table->time('soundcheck_time')->nullable();
|
|
$table->decimal('fee', 10, 2)->nullable();
|
|
$table->string('currency', 3)->default('EUR');
|
|
$table->enum('status', ['draft', 'pending', 'confirmed', 'completed', 'cancelled'])->default('draft');
|
|
$table->enum('visibility', ['private', 'members', 'public'])->default('members');
|
|
$table->dateTime('rsvp_deadline')->nullable();
|
|
$table->text('notes')->nullable();
|
|
$table->text('internal_notes')->nullable();
|
|
$table->boolean('is_public_setlist')->default(false);
|
|
$table->foreignUlid('created_by')->constrained('users');
|
|
$table->timestamps();
|
|
|
|
$table->index(['event_date', 'status']);
|
|
});
|
|
}
|
|
|
|
public function down(): void
|
|
{
|
|
Schema::dropIfExists('events');
|
|
}
|
|
};
|
|
```
|
|
|
|
### Controller
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\Api\V1;
|
|
|
|
use App\Actions\Events\CreateEventAction;
|
|
use App\Actions\Events\UpdateEventAction;
|
|
use App\Http\Controllers\Controller;
|
|
use App\Http\Requests\Api\V1\StoreEventRequest;
|
|
use App\Http\Requests\Api\V1\UpdateEventRequest;
|
|
use App\Http\Resources\Api\V1\EventCollection;
|
|
use App\Http\Resources\Api\V1\EventResource;
|
|
use App\Models\Event;
|
|
use Illuminate\Http\JsonResponse;
|
|
|
|
final class EventController extends Controller
|
|
{
|
|
public function index(): EventCollection
|
|
{
|
|
$events = Event::query()
|
|
->with(['location', 'customer'])
|
|
->latest('event_date')
|
|
->paginate();
|
|
|
|
return new EventCollection($events);
|
|
}
|
|
|
|
public function store(StoreEventRequest $request, CreateEventAction $action): JsonResponse
|
|
{
|
|
$event = $action->execute($request->validated());
|
|
|
|
return $this->created(
|
|
new EventResource($event->load(['location', 'customer'])),
|
|
'Event created successfully'
|
|
);
|
|
}
|
|
|
|
public function show(Event $event): EventResource
|
|
{
|
|
return new EventResource(
|
|
$event->load(['location', 'customer', 'setlist', 'invitations.user'])
|
|
);
|
|
}
|
|
|
|
public function update(UpdateEventRequest $request, Event $event, UpdateEventAction $action): JsonResponse
|
|
{
|
|
$event = $action->execute($event, $request->validated());
|
|
|
|
return $this->success(
|
|
new EventResource($event),
|
|
'Event updated successfully'
|
|
);
|
|
}
|
|
|
|
public function destroy(Event $event): JsonResponse
|
|
{
|
|
$event->delete();
|
|
|
|
return $this->success(null, 'Event deleted successfully');
|
|
}
|
|
}
|
|
```
|
|
|
|
### Form Request
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Requests\Api\V1;
|
|
|
|
use App\Enums\EventStatus;
|
|
use App\Enums\EventVisibility;
|
|
use Illuminate\Foundation\Http\FormRequest;
|
|
use Illuminate\Validation\Rule;
|
|
|
|
final class StoreEventRequest extends FormRequest
|
|
{
|
|
public function authorize(): bool
|
|
{
|
|
return true; // Or use policy
|
|
}
|
|
|
|
public function rules(): array
|
|
{
|
|
return [
|
|
'title' => ['required', 'string', 'max:255'],
|
|
'description' => ['nullable', 'string', 'max:5000'],
|
|
'location_id' => ['nullable', 'ulid', 'exists:locations,id'],
|
|
'customer_id' => ['nullable', 'ulid', 'exists:customers,id'],
|
|
'setlist_id' => ['nullable', 'ulid', 'exists:setlists,id'],
|
|
'event_date' => ['required', 'date', 'after_or_equal:today'],
|
|
'start_time' => ['required', 'date_format:H:i'],
|
|
'end_time' => ['nullable', 'date_format:H:i', 'after:start_time'],
|
|
'load_in_time' => ['nullable', 'date_format:H:i'],
|
|
'soundcheck_time' => ['nullable', 'date_format:H:i'],
|
|
'fee' => ['nullable', 'numeric', 'min:0', 'max:999999.99'],
|
|
'currency' => ['sometimes', 'string', 'size:3'],
|
|
'status' => ['sometimes', Rule::enum(EventStatus::class)],
|
|
'visibility' => ['sometimes', Rule::enum(EventVisibility::class)],
|
|
'rsvp_deadline' => ['nullable', 'date', 'before:event_date'],
|
|
'notes' => ['nullable', 'string', 'max:5000'],
|
|
'internal_notes' => ['nullable', 'string', 'max:5000'],
|
|
];
|
|
}
|
|
|
|
public function messages(): array
|
|
{
|
|
return [
|
|
'event_date.after_or_equal' => 'The event date must be today or a future date.',
|
|
'end_time.after' => 'The end time must be after the start time.',
|
|
];
|
|
}
|
|
}
|
|
```
|
|
|
|
### API Resource
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Resources\Api\V1;
|
|
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Http\Resources\Json\JsonResource;
|
|
|
|
final class EventResource extends JsonResource
|
|
{
|
|
public function toArray(Request $request): array
|
|
{
|
|
return [
|
|
'id' => $this->id,
|
|
'title' => $this->title,
|
|
'description' => $this->description,
|
|
'event_date' => $this->event_date->toDateString(),
|
|
'start_time' => $this->start_time?->format('H:i'),
|
|
'end_time' => $this->end_time?->format('H:i'),
|
|
'load_in_time' => $this->load_in_time?->format('H:i'),
|
|
'soundcheck_time' => $this->soundcheck_time?->format('H:i'),
|
|
'fee' => $this->fee,
|
|
'currency' => $this->currency,
|
|
'status' => $this->status->value,
|
|
'status_label' => $this->status->label(),
|
|
'visibility' => $this->visibility,
|
|
'rsvp_deadline' => $this->rsvp_deadline?->toIso8601String(),
|
|
'notes' => $this->notes,
|
|
'internal_notes' => $this->when(
|
|
$request->user()?->isAdmin(),
|
|
$this->internal_notes
|
|
),
|
|
'location' => new LocationResource($this->whenLoaded('location')),
|
|
'customer' => new CustomerResource($this->whenLoaded('customer')),
|
|
'setlist' => new SetlistResource($this->whenLoaded('setlist')),
|
|
'invitations' => EventInvitationResource::collection(
|
|
$this->whenLoaded('invitations')
|
|
),
|
|
'created_at' => $this->created_at->toIso8601String(),
|
|
'updated_at' => $this->updated_at->toIso8601String(),
|
|
];
|
|
}
|
|
}
|
|
```
|
|
|
|
### Resource Collection
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Resources\Api\V1;
|
|
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Http\Resources\Json\ResourceCollection;
|
|
|
|
final class EventCollection extends ResourceCollection
|
|
{
|
|
public $collects = EventResource::class;
|
|
|
|
public function toArray(Request $request): array
|
|
{
|
|
return [
|
|
'data' => $this->collection,
|
|
];
|
|
}
|
|
|
|
public function with(Request $request): array
|
|
{
|
|
return [
|
|
'success' => true,
|
|
'meta' => [
|
|
'pagination' => [
|
|
'current_page' => $this->currentPage(),
|
|
'per_page' => $this->perPage(),
|
|
'total' => $this->total(),
|
|
'last_page' => $this->lastPage(),
|
|
'from' => $this->firstItem(),
|
|
'to' => $this->lastItem(),
|
|
],
|
|
],
|
|
];
|
|
}
|
|
}
|
|
```
|
|
|
|
### Action Class
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Actions\Events;
|
|
|
|
use App\Models\Event;
|
|
use Illuminate\Support\Facades\Auth;
|
|
|
|
final class CreateEventAction
|
|
{
|
|
public function execute(array $data): Event
|
|
{
|
|
$data['created_by'] = Auth::id();
|
|
|
|
return Event::create($data);
|
|
}
|
|
}
|
|
```
|
|
|
|
### API Response Trait
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Traits;
|
|
|
|
use Illuminate\Http\JsonResponse;
|
|
|
|
trait ApiResponse
|
|
{
|
|
protected function success(mixed $data = null, string $message = 'Success', int $code = 200): JsonResponse
|
|
{
|
|
return response()->json([
|
|
'success' => true,
|
|
'data' => $data,
|
|
'message' => $message,
|
|
], $code);
|
|
}
|
|
|
|
protected function created(mixed $data = null, string $message = 'Created'): JsonResponse
|
|
{
|
|
return $this->success($data, $message, 201);
|
|
}
|
|
|
|
protected function error(string $message, int $code = 400, array $errors = []): JsonResponse
|
|
{
|
|
$response = [
|
|
'success' => false,
|
|
'message' => $message,
|
|
];
|
|
|
|
if (!empty($errors)) {
|
|
$response['errors'] = $errors;
|
|
}
|
|
|
|
return response()->json($response, $code);
|
|
}
|
|
|
|
protected function notFound(string $message = 'Resource not found'): JsonResponse
|
|
{
|
|
return $this->error($message, 404);
|
|
}
|
|
|
|
protected function unauthorized(string $message = 'Unauthorized'): JsonResponse
|
|
{
|
|
return $this->error($message, 401);
|
|
}
|
|
|
|
protected function forbidden(string $message = 'Forbidden'): JsonResponse
|
|
{
|
|
return $this->error($message, 403);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Base Controller
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Traits\ApiResponse;
|
|
|
|
abstract class Controller
|
|
{
|
|
use ApiResponse;
|
|
}
|
|
```
|
|
|
|
### Policy
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Policies;
|
|
|
|
use App\Models\Event;
|
|
use App\Models\User;
|
|
|
|
final class EventPolicy
|
|
{
|
|
public function viewAny(User $user): bool
|
|
{
|
|
return true;
|
|
}
|
|
|
|
public function view(User $user, Event $event): bool
|
|
{
|
|
return true;
|
|
}
|
|
|
|
public function create(User $user): bool
|
|
{
|
|
return $user->isAdmin() || $user->isBookingAgent();
|
|
}
|
|
|
|
public function update(User $user, Event $event): bool
|
|
{
|
|
return $user->isAdmin() || $user->isBookingAgent();
|
|
}
|
|
|
|
public function delete(User $user, Event $event): bool
|
|
{
|
|
return $user->isAdmin();
|
|
}
|
|
}
|
|
```
|
|
|
|
### Routes (api.php)
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Http\Controllers\Api\V1\AuthController;
|
|
use App\Http\Controllers\Api\V1\EventController;
|
|
use App\Http\Controllers\Api\V1\LocationController;
|
|
use App\Http\Controllers\Api\V1\MemberController;
|
|
use App\Http\Controllers\Api\V1\MusicController;
|
|
use App\Http\Controllers\Api\V1\SetlistController;
|
|
use App\Http\Controllers\Api\V1\CustomerController;
|
|
use Illuminate\Support\Facades\Route;
|
|
|
|
Route::prefix('v1')->group(function () {
|
|
// Public routes
|
|
Route::post('auth/login', [AuthController::class, 'login']);
|
|
Route::post('auth/register', [AuthController::class, 'register']);
|
|
Route::post('auth/forgot-password', [AuthController::class, 'forgotPassword']);
|
|
Route::post('auth/reset-password', [AuthController::class, 'resetPassword']);
|
|
|
|
// Protected routes
|
|
Route::middleware('auth:sanctum')->group(function () {
|
|
// Auth
|
|
Route::get('auth/user', [AuthController::class, 'user']);
|
|
Route::post('auth/logout', [AuthController::class, 'logout']);
|
|
|
|
// Resources
|
|
Route::apiResource('events', EventController::class);
|
|
Route::post('events/{event}/invite', [EventController::class, 'invite']);
|
|
Route::post('events/{event}/rsvp', [EventController::class, 'rsvp']);
|
|
|
|
Route::apiResource('members', MemberController::class);
|
|
Route::apiResource('music', MusicController::class);
|
|
Route::apiResource('setlists', SetlistController::class);
|
|
Route::apiResource('locations', LocationController::class);
|
|
Route::apiResource('customers', CustomerController::class);
|
|
});
|
|
});
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
### Always Use
|
|
|
|
- `declare(strict_types=1)` at the top of every file
|
|
- `final` keyword for Action classes, Form Requests, Resources
|
|
- Type hints for all parameters and return types
|
|
- Named arguments for better readability
|
|
- Enums for status fields and fixed options
|
|
- ULIDs for all primary keys
|
|
- Eager loading to prevent N+1 queries
|
|
- API Resources for all responses
|
|
|
|
### Avoid
|
|
|
|
- Business logic in controllers
|
|
- String constants (use enums)
|
|
- Auto-increment IDs
|
|
- Direct model creation in controllers
|
|
- Returning raw models (use Resources)
|
|
- Hardcoded strings for error messages
|
|
|
|
## DTOs (Data Transfer Objects)
|
|
|
|
Use DTOs for complex data passing between layers:
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\DTOs;
|
|
|
|
readonly class CreateEventDTO
|
|
{
|
|
public function __construct(
|
|
public string $title,
|
|
public string $eventDate,
|
|
public string $startTime,
|
|
public ?string $description = null,
|
|
public ?string $locationId = null,
|
|
public ?string $customerId = null,
|
|
public ?string $endTime = null,
|
|
public ?float $fee = null,
|
|
) {}
|
|
|
|
public static function from(array $data): self
|
|
{
|
|
return new self(
|
|
title: $data['title'],
|
|
eventDate: $data['event_date'],
|
|
startTime: $data['start_time'],
|
|
description: $data['description'] ?? null,
|
|
locationId: $data['location_id'] ?? null,
|
|
customerId: $data['customer_id'] ?? null,
|
|
endTime: $data['end_time'] ?? null,
|
|
fee: $data['fee'] ?? null,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Usage
|
|
$dto = CreateEventDTO::from($request->validated());
|
|
$event = $action->execute($dto);
|
|
```
|
|
|
|
## Helpers
|
|
|
|
Use Laravel helpers instead of facades:
|
|
|
|
```php
|
|
// ✅ Good
|
|
auth()->id()
|
|
auth()->user()
|
|
now()
|
|
str($string)->slug()
|
|
collect($array)->filter()
|
|
cache()->remember('key', 3600, fn() => $value)
|
|
|
|
// ❌ Avoid
|
|
Auth::id()
|
|
Carbon::now()
|
|
Str::slug($string)
|
|
Cache::remember(...)
|
|
```
|
|
|
|
## Error Handling
|
|
|
|
Create domain-specific exceptions:
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Exceptions;
|
|
|
|
use Exception;
|
|
use Illuminate\Http\JsonResponse;
|
|
|
|
class EventNotFoundException extends Exception
|
|
{
|
|
public function render(): JsonResponse
|
|
{
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Event not found',
|
|
], 404);
|
|
}
|
|
}
|
|
|
|
class EventAlreadyConfirmedException extends Exception
|
|
{
|
|
public function render(): JsonResponse
|
|
{
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Event has already been confirmed and cannot be modified',
|
|
], 422);
|
|
}
|
|
}
|
|
|
|
// Usage in Action
|
|
if ($event->isConfirmed()) {
|
|
throw new EventAlreadyConfirmedException();
|
|
}
|
|
```
|
|
|
|
## Query Scopes
|
|
|
|
Add reusable query scopes to models:
|
|
|
|
```php
|
|
// In Event model
|
|
public function scopeUpcoming(Builder $query): Builder
|
|
{
|
|
return $query->where('event_date', '>=', now()->toDateString())
|
|
->orderBy('event_date');
|
|
}
|
|
|
|
public function scopeForUser(Builder $query, User $user): Builder
|
|
{
|
|
return $query->whereHas('invitations', fn ($q) =>
|
|
$q->where('user_id', $user->id)
|
|
);
|
|
}
|
|
|
|
public function scopeConfirmed(Builder $query): Builder
|
|
{
|
|
return $query->where('status', EventStatus::Confirmed);
|
|
}
|
|
|
|
// Usage
|
|
Event::upcoming()->confirmed()->get();
|
|
Event::forUser($user)->upcoming()->get();
|
|
```
|