whereKey($performance->id) ->lockForUpdate() ->firstOrFail(); if ((int) $locked->version !== $clientVersion) { throw new VersionMismatchException( currentVersion: (int) $locked->version, clientVersion: $clientVersion, ); } if ($targetStage === null) { $locked->stage_id = null; if ($start !== null) { $locked->start_at = $start; } if ($end !== null) { $locked->end_at = $end; } if ($targetLane !== null) { $locked->lane = $targetLane; } $locked->save(); return new MoveResult($locked->refresh(), []); } if ($start === null || $end === null || $targetLane === null) { throw new \InvalidArgumentException( 'targetStage non-null requires start, end and targetLane.', ); } $newEventId = $this->resolveEventIdForStageAndStart($targetStage, $start) ?? $performance->event_id; // Lock all peer performances on (stage, event, lane) for the // duration of the cascade. The first iteration locks the // already-existing rows; new placements that follow this // transaction wait on these row locks until commit. $existingOnLane = Performance::query() ->where('stage_id', $targetStage->id) ->where('event_id', $newEventId) ->where('lane', $targetLane) ->where('id', '!=', $locked->id) ->lockForUpdate() ->get(); $cascaded = []; foreach ($existingOnLane as $other) { if ($this->overlaps($start, $end, $other->start_at, $other->end_at)) { $other->lane = (int) $other->lane + 1; $other->save(); $cascaded[] = $other->refresh(); } } $locked->stage_id = $targetStage->id; $locked->event_id = $newEventId; $locked->start_at = $start; $locked->end_at = $end; $locked->lane = $targetLane; $locked->save(); return new MoveResult($locked->refresh(), $cascaded); }); } /** * For festivals, `target_start_at` falls inside one of the stage's * sub-events; for flat events, the stage's own event_id is the * answer. Returns null if no stage_day matches โ€” caller falls back * to keeping the performance's existing event_id (validation rules * in the FormRequest already guarantee a valid stage_day exists). */ private function resolveEventIdForStageAndStart(Stage $stage, CarbonImmutable $start): ?string { return $stage->stageDays() ->join('events', 'events.id', '=', 'stage_days.event_id') ->where('events.start_at', '<=', $start) ->where('events.end_at', '>=', $start) ->orderBy('events.start_at', 'desc') ->limit(1) ->value('stage_days.event_id'); } private function overlaps( CarbonImmutable $aStart, CarbonImmutable $aEnd, \DateTimeInterface $bStart, \DateTimeInterface $bEnd, ): bool { return $aStart < CarbonImmutable::instance($bEnd) && CarbonImmutable::instance($bStart) < $aEnd; } }