--- description: Laravel API development guidelines for EventCrew multi-tenant platform globs: ["api/**/*.php"] alwaysApply: true --- # Laravel API Development Rules ## PHP Conventions - Use PHP 8.2+ features: constructor property promotion, readonly properties, match expressions, enums - Use `declare(strict_types=1);` in all files - Use `match` over `switch` wherever possible - Import all classes with `use` statements - Prefer early returns over nested conditionals ## Core Principles 1. **API-only** - No Blade views, no web routes. Every response is JSON. 2. **Multi-tenant** - Every query scoped on `organisation_id` via Global Scope. 3. **Resource Controllers** - Use index/show/store/update/destroy. 4. **Validate via Form Requests** - Never inline `validate()`. 5. **Authorize via Policies** - Never hardcode role strings in controllers. 6. **Respond via API Resources** - Never return model attributes directly. 7. **ULID primary keys** - Via HasUlids trait on all business models. ## File Templates ### Model (with OrganisationScope) ```php 'date', 'end_date' => 'date', 'status' => EventStatus::class, ]; // Global Scope: always scope on organisation protected static function booted(): void { static::addGlobalScope('organisation', function (Builder $builder) { if ($organisationId = auth()->user()?->current_organisation_id) { $builder->where('organisation_id', $organisationId); } }); } // Relationships public function organisation(): BelongsTo { return $this->belongsTo(Organisation::class); } public function festivalSections(): HasMany { return $this->hasMany(FestivalSection::class); } public function timeSlots(): HasMany { return $this->hasMany(TimeSlot::class); } public function persons(): HasMany { return $this->hasMany(Person::class); } public function artists(): HasMany { return $this->hasMany(Artist::class); } // Scopes public function scopeWithStatus(Builder $query, EventStatus $status): Builder { return $query->where('status', $status); } } ``` ### Enum (EventStatus) ```php 'Draft', self::Published => 'Published', self::RegistrationOpen => 'Registration Open', self::BuildUp => 'Build-Up', self::ShowDay => 'Show Day', self::TearDown => 'Tear-Down', self::Closed => 'Closed', }; } public function color(): string { return match ($this) { self::Draft => 'secondary', self::Published => 'info', self::RegistrationOpen => 'primary', self::BuildUp => 'warning', self::ShowDay => 'success', self::TearDown => 'warning', self::Closed => 'secondary', }; } } ``` ### Migration ```php ulid('id')->primary(); $table->foreignUlid('organisation_id')->constrained()->cascadeOnDelete(); $table->string('name'); $table->string('slug'); $table->date('start_date'); $table->date('end_date'); $table->string('timezone')->default('Europe/Amsterdam'); $table->string('status')->default('draft'); $table->timestamps(); $table->softDeletes(); $table->index(['organisation_id', 'status']); }); } public function down(): void { Schema::dropIfExists('events'); } }; ``` ### Controller (Resource Controller with Policy) ```php authorizeResource(Event::class, 'event'); } public function index(): AnonymousResourceCollection { $events = Event::query() ->with(['organisation', 'festivalSections']) ->latest('start_date') ->paginate(); return EventResource::collection($events); } public function store(StoreEventRequest $request): JsonResponse { $event = Event::create($request->validated()); return (new EventResource($event)) ->response() ->setStatusCode(201); } public function show(Event $event): EventResource { return new EventResource( $event->load(['organisation', 'festivalSections', 'timeSlots', 'persons']) ); } public function update(UpdateEventRequest $request, Event $event): EventResource { $event->update($request->validated()); return new EventResource($event); } public function destroy(Event $event): JsonResponse { $event->delete(); return response()->json(null, 204); } } ``` ### Form Request ```php ['required', 'ulid', 'exists:organisations,id'], 'name' => ['required', 'string', 'max:255'], 'slug' => ['required', 'string', 'max:255', Rule::unique('events')->where('organisation_id', $this->organisation_id)], 'start_date' => ['required', 'date'], 'end_date' => ['required', 'date', 'after_or_equal:start_date'], 'timezone' => ['sometimes', 'string', 'timezone'], 'status' => ['sometimes', Rule::enum(EventStatus::class)], ]; } } ``` ### API Resource ```php $this->id, 'organisation_id' => $this->organisation_id, 'name' => $this->name, 'slug' => $this->slug, 'start_date' => $this->start_date->toDateString(), 'end_date' => $this->end_date->toDateString(), 'timezone' => $this->timezone, 'status' => $this->status->value, 'status_label' => $this->status->label(), 'status_color' => $this->status->color(), 'festival_sections' => FestivalSectionResource::collection( $this->whenLoaded('festivalSections') ), 'time_slots' => TimeSlotResource::collection( $this->whenLoaded('timeSlots') ), 'persons_count' => $this->when( $this->persons_count !== null, $this->persons_count ), 'created_at' => $this->created_at->toIso8601String(), 'updated_at' => $this->updated_at->toIso8601String(), ]; } } ``` ### Policy (with Spatie Roles) ```php hasAnyRole(['super_admin', 'org_admin', 'org_member', 'org_readonly']); } public function view(User $user, Event $event): bool { return $user->belongsToOrganisation($event->organisation_id); } public function create(User $user): bool { return $user->hasAnyRole(['super_admin', 'org_admin']); } public function update(User $user, Event $event): bool { return $user->hasAnyRole(['super_admin', 'org_admin']) && $user->belongsToOrganisation($event->organisation_id); } public function delete(User $user, Event $event): bool { return $user->hasAnyRole(['super_admin', 'org_admin']) && $user->belongsToOrganisation($event->organisation_id); } } ``` ### Routes (api.php) ```php group(function () { // Public routes Route::post('auth/login', [V1\AuthController::class, 'login']); Route::post('portal/token-auth', [V1\PortalAuthController::class, 'tokenAuth']); // Protected routes (login-based) Route::middleware('auth:sanctum')->group(function () { Route::post('auth/logout', [V1\AuthController::class, 'logout']); Route::get('auth/me', [V1\AuthController::class, 'me']); // Organisations Route::apiResource('organisations', V1\OrganisationController::class); Route::post('organisations/{organisation}/invite', [V1\OrganisationController::class, 'invite']); // Events (nested under organisations) Route::apiResource('organisations.events', V1\EventController::class)->shallow(); // Festival Sections (nested under events) Route::apiResource('events.festival-sections', V1\FestivalSectionController::class)->shallow(); // Time Slots Route::apiResource('events.time-slots', V1\TimeSlotController::class)->shallow(); // Shifts (nested under sections) Route::apiResource('festival-sections.shifts', V1\ShiftController::class)->shallow(); Route::post('shifts/{shift}/assign', [V1\ShiftController::class, 'assign']); Route::post('shifts/{shift}/claim', [V1\ShiftController::class, 'claim']); // Persons Route::apiResource('events.persons', V1\PersonController::class)->shallow(); Route::post('persons/{person}/approve', [V1\PersonController::class, 'approve']); Route::post('persons/{person}/checkin', [V1\PersonController::class, 'checkin']); // Artists Route::apiResource('events.artists', V1\ArtistController::class)->shallow(); // Accreditation Route::apiResource('events.accreditation-items', V1\AccreditationItemController::class)->shallow(); Route::apiResource('events.access-zones', V1\AccessZoneController::class)->shallow(); // Briefings Route::apiResource('events.briefings', V1\BriefingController::class)->shallow(); Route::post('briefings/{briefing}/send', [V1\BriefingController::class, 'send']); }); // Token-based portal routes Route::middleware('portal.token')->prefix('portal')->group(function () { Route::get('artist', [V1\PortalArtistController::class, 'show']); Route::post('advancing', [V1\PortalArtistController::class, 'submitAdvance']); Route::get('supplier', [V1\PortalSupplierController::class, 'show']); Route::post('production-request', [V1\PortalSupplierController::class, 'submit']); }); }); ``` ## Soft Delete Strategy **Soft delete ON**: Organisation, Event, FestivalSection, Shift, ShiftAssignment, Person, Artist, Company, ProductionRequest. **Soft delete OFF** (immutable audit records): CheckIn, BriefingSend, MessageReply, ShiftWaitlist, VolunteerFestivalHistory. ## Best Practices ### Always Use - `declare(strict_types=1)` at top of every file - HasUlids trait for ULID primary keys on business models - OrganisationScope for multi-tenant data isolation - Type hints for all parameters and return types - Enums for status fields and fixed options - Eager loading to prevent N+1 queries - API Resources for all responses (never raw models) - Spatie roles and Policies for authorization - Composite indexes as documented in design document ### Avoid - Business logic in controllers (use Services for complex logic) - String constants for statuses (use enums) - Auto-increment IDs for business tables (use ULIDs) - Returning raw models (use Resources) - Hardcoded role checks in controllers (use Policies) - JSON columns for data that needs to be filtered/sorted - `Model::all()` without organisation scoping