'organisation_id'] — direct scope column, OR * ['via' => ParentModel::class, 'fk' => 'parent_id'] — walk to parent * The resolver recurses through parents until it hits a column strategy * or the legacy $organisationScopeColumn bridge. Cap at 5 hops; deeper * chains raise TenantScopeResolutionException. * * Cap raised from 3 to 5 in WS-5c (2026-04-26). Legitimate relational * trees — `form_field_conditional_logic_conditions → group → field → * schema → org` — exceed the original limit. Raising to 5 accommodates * this without denormalising `form_field_id` onto conditions (which * would introduce drift risk). * * (b) Legacy column property (pre-addendum — still supported so Person, * Event, FestivalSection, etc. need not migrate in this work package): * `public string $organisationScopeColumn = 'organisation_id' * | 'event_id' * | 'festival_section_id';` * The resolver treats this as a terminal column-based strategy. * * The recursive walker accepts EITHER shape at every hop, so a model using * the new tenantScopeStrategy() can chain through a parent using the legacy * $organisationScopeColumn (e.g. PersonIdentityMatch → Person → event_id → * organisation_id), and vice versa. */ final class OrganisationScope implements Scope { private const MAX_CHAIN_HOPS = 5; public function __construct( private readonly ?string $organisationId = null, ) {} public function apply(Builder $builder, Model $model): void { $id = $this->resolveOrganisationId(); if ($id === null) { return; } $this->applyStrategy($builder, $model, $id, 0); } /** * Apply a tenant filter by walking the scope strategy declared on * $model down to the first column-based strategy. */ private function applyStrategy(Builder $builder, Model $model, string $orgId, int $hops): void { if ($hops > self::MAX_CHAIN_HOPS) { throw TenantScopeResolutionException::tooDeep($model::class, self::MAX_CHAIN_HOPS); } $strategy = $this->strategyFor($model); if (isset($strategy['column'])) { $this->applyColumnFilter($builder, $model, (string) $strategy['column'], $orgId); return; } if (isset($strategy['via'], $strategy['fk'])) { /** @var class-string $parentClass */ $parentClass = $strategy['via']; $fk = (string) $strategy['fk']; $parentInstance = new $parentClass(); $parentTable = $parentInstance->getTable(); $parentKey = $parentInstance->getKeyName(); // Build the subquery constraining the parent by the SAME // recursive strategy walk — parent may itself be chained. $parentQuery = $parentClass::query() ->withoutGlobalScope(self::class) ->select("$parentTable.$parentKey"); $this->applyStrategy($parentQuery, $parentInstance, $orgId, $hops + 1); $builder->whereIn($model->getTable().'.'.$fk, $parentQuery); return; } throw TenantScopeResolutionException::invalidStrategy($model::class); } /** * Discover the scope strategy for a model. Accepts: * - tenantScopeStrategy() static method returning ['column'=>…] or ['via'=>…,'fk'=>…] * - Legacy $organisationScopeColumn instance property * @return array{column?: string, via?: class-string, fk?: string} */ private function strategyFor(Model $model): array { if (method_exists($model, 'tenantScopeStrategy')) { /** @var array $declared */ $declared = $model::tenantScopeStrategy(); return $declared; } // Legacy bridge — existing column-scoped models continue to work // without migrating to tenantScopeStrategy() in this work package. $column = $model->organisationScopeColumn ?? 'organisation_id'; return ['column' => $column]; } /** * Terminal column filter. `organisation_id` is a direct where; the * legacy `event_id` / `festival_section_id` strategies expand to the * same subquery pattern the old implementation used. */ private function applyColumnFilter(Builder $builder, Model $model, string $column, string $orgId): void { $table = $model->getTable(); match ($column) { 'organisation_id' => $builder->where("$table.organisation_id", $orgId), 'event_id' => $builder->whereIn( "$table.event_id", Event::withoutGlobalScope(self::class) ->where('organisation_id', $orgId) ->select('id') ), 'festival_section_id' => $builder->whereIn( "$table.festival_section_id", \App\Models\FestivalSection::withoutGlobalScope(self::class) ->whereIn('event_id', Event::withoutGlobalScope(self::class) ->where('organisation_id', $orgId) ->select('id')) ->select('id') ), default => throw TenantScopeResolutionException::invalidStrategy($model::class), }; } private function resolveOrganisationId(): ?string { if ($this->organisationId !== null) { return $this->organisationId; } $route = request()->route(); if ($route === null) { return null; } $org = $route->parameter('organisation'); if ($org instanceof \App\Models\Organisation) { return $org->id; } if (is_string($org) && $org !== '') { return $org; } // Fall back to the event's organisation if we're on an event route $event = $route->parameter('event'); if ($event instanceof Event) { return $event->organisation_id; } return null; } }