--- 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 '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 '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 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 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 ['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 $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 $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 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 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 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 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 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(); ```