feat: initial commit - Band Management application

This commit is contained in:
2026-01-06 03:11:46 +01:00
commit 34e12e00b3
24543 changed files with 3991790 additions and 0 deletions

View File

@@ -0,0 +1,786 @@
---
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();
```