Files
band-management/.cursor/rules/100_laravel.mdc
bert.hausmans 1cb7674d52 refactor: align codebase with EventCrew domain and trim legacy band stack
- Update API: events, users, policies, routes, resources, migrations
- Remove deprecated models/resources (customers, setlists, invitations, etc.)
- Refresh admin app and docs; remove apps/band

Made-with: Cursor
2026-03-29 23:19:06 +02:00

482 lines
14 KiB
Plaintext

---
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
<?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;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Builder;
class Event extends Model
{
use HasFactory;
use HasUlids;
use SoftDeletes;
protected $fillable = [
'organisation_id',
'name',
'slug',
'start_date',
'end_date',
'timezone',
'status',
];
protected $casts = [
'start_date' => '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
<?php
declare(strict_types=1);
namespace App\Enums;
enum EventStatus: string
{
case Draft = 'draft';
case Published = 'published';
case RegistrationOpen = 'registration_open';
case BuildUp = 'buildup';
case ShowDay = 'showday';
case TearDown = 'teardown';
case Closed = 'closed';
public function label(): string
{
return match ($this) {
self::Draft => '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
<?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->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
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
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\EventResource;
use App\Models\Event;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class EventController extends Controller
{
public function __construct()
{
$this->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
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1;
use App\Enums\EventStatus;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreEventRequest extends FormRequest
{
public function authorize(): bool
{
return true; // Handled by Policy via authorizeResource
}
public function rules(): array
{
return [
'organisation_id' => ['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
<?php
declare(strict_types=1);
namespace App\Http\Resources\Api\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class EventResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $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
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\Event;
use App\Models\User;
class EventPolicy
{
public function viewAny(User $user): bool
{
return $user->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
<?php
declare(strict_types=1);
use App\Http\Controllers\Api\V1;
use Illuminate\Support\Facades\Route;
Route::prefix('v1')->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