- 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
482 lines
14 KiB
Plaintext
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
|