Be part of JetBrains PHPverse 2026 on June 9 – a free online event bringing PHP devs worldwide together.

lim105's avatar

scheduling conflict

Hi, I’m working on a Laravel project for managing physiotherapy treatments for uni. I need to prevent scheduling conflicts, so that the same room, physiotherapist, or patient cannot be booked for overlapping time slots on the same day.

This is a simplified version of my controller code for reference, but i don't understand why it doesn't work correctly:

class TreatmentController extends Controller { // ...existing index method...

public function store(StoreTrattamentiRequest $request)
{
    Gate::authorize('create', Treatment::class);
    DB::beginTransaction();
    $user_id = UserService::getUser()['id'];
    $request->merge(['user_id' => $user_id]);
    try {
        $price = floatval($request->input('prezzo'));
        $paid = floatval($request->input('importo_pagato'));

        // PAID IN FULL
        if ($paid == $price) {
            $status = TreatmentStatus::where('nome', 'Saldato')->firstOrFail();
            $request->merge(['stato_trattamento_id' => $status->id, 'importo_da_pagare' => 0]);
        }

        // NOT PAID
        if ($paid == 0) {
            $status = TreatmentStatus::where('nome', 'Non saldato')->firstOrFail();
            $request->merge(['stato_trattamento_id' => $status->id, 'importo_da_pagare' => $price]);
        }

        // DISCOUNTED OR PARTIALLY PAID
        else {
            $status_id = $request->input('stato_trattamento_id');
            $status = TreatmentStatus::find($status_id);
            $remaining = $status->nome === 'Sconto' ? 0 : ($price - $paid);
            $request->merge(['importo_da_pagare' => $remaining]);
            if (!$status_id) {
                return response()->json([
                    'message' => 'For partial payments, please specify if this is a discount or partial payment.'
                ], 422);
            }
        }

        $patientIds = $this->getPatients($request);

        
        if ($this->conflictRoom($request)) {
            return response()->json(['message' => 'The room is already booked for the selected time slot'], 409);
        }
        if ($this->conflictPhysiotherapist($request)) {
            return response()->json(['message' => 'The physiotherapist is already booked for the selected time slot'], 409);
        }
        if ($this->conflictPatient($patientIds, $request)) {
            return response()->json(['message' => 'The patient is already booked for the selected time slot'], 409);
        }
        if ($this->conflictTime($request)) {
            return response()->json(['message' => 'End time cannot be earlier than start time'], 409);
        }
        // --- END CONCURRENCY ---

        $treatment = Treatment::create($request->all());
        $treatment->patients()->attach($patientIds);
        DB::commit();
    } catch (Throwable $e) {
        DB::rollBack();
        throw $e;
    }
    return response()->json(compact('treatment'));
}

private function getPatients(Request $request): array
{
    $ids = [];
    foreach ($request->input('pazienti') as $patient) {
        $ids[] = $patient['id'];
    }
    return $ids;
}

private function checkConflict(Builder $query, Request $request, $excludeId = null): void
{
    $start = $request->input('ora_inizio');
    $end = $request->input('ora_fine');

    $query->where(function ($subQuery) use ($start, $end) {
        $subQuery->where('ora_inizio', '<', $end)
                 ->where('ora_fine', '>', $start);
    });

    
    if ($excludeId) {
        $query->where('id', '!=', $excludeId);
    }
}

private function conflictRoom(Request $request, $excludeId = null): bool
{
    $date = $request->input('data');
    $roomId = $request->input('stanza_id');
    $query = Treatment::where('data', $date)->where('stanza_id', $roomId);
    $this->checkConflict($query, $request, $excludeId);
    return $query->exists();
}

private function conflictPhysiotherapist(Request $request, $excludeId = null): bool
{
    $date = $request->input('data');
    $physioId = $request->input('fisioterapista_id');
    $query = Treatment::where('data', $date)->where('fisioterapista_id', $physioId);
    $this->checkConflict($query, $request, $excludeId);
    return $query->exists();
}

private function conflictPatient(array $patients, Request $request, $excludeId = null): bool
{
    $date = $request->input('data');
    if ($patients != null) {
        foreach ($patients as $patientId) {
            $query = Treatment::where('data', $date)
                ->whereHas('patients', function ($subQuery) use ($patientId) {
                    $subQuery->where('paziente_id', $patientId);
                });
            $this->checkConflict($query, $request, $excludeId);
            if ($query->exists()) {
                return true;
            }
        }
    }
    return false;
}

private function conflictTime(Request $request): bool
{
    $start = $request->input('ora_inizio');
    $end = $request->input('ora_fine');
    return $start >= $end;
}
1 like
4 replies
Glukinho's avatar

What is "it doesn't work correctly"? What is wrong happens on what circumstances?

In general, I would have an Appointment model along with the others, which connects room + doctor + patient + date/time range together.

All checks for preventing overlaps I would have in dedicated AppointmentService:

class AppointmentService
{
	public function create(
		\DateTimeInterface $start,
		\DateTimeInterface $end,
		Room $room,
		Doctor $doctor,
		Patient $patient,
		Procedure $procedure, // why not? You definitely would want to store procedures in your app
	): Appointment 
	{
		if ( ! $this->room->isAvailableAt($start, $end)) {
			throw new RoomIsUnavailableException("Room {$room->name} is unavailable from {$start->format('Y-m-d H:i:s')} to {$end->format('Y-m-d H:i:s')}");
		}

		// check doctor is available, throw an exception if not...
		// check patient is free, throw an exception if not...
		// ...any other related checks...

		return Appointment::create(/* ... */);
	}
}

Then, in controller:

public function __construct(
	private AppointmentService $appointmentService,
) { }

public function store(StoreTrattamentiRequest $request)
{
	// ...validation, authorization...

	try {
		$appointment = $this->appointmentService->create(
			start:  new \DateTimeImmutable($request->input('start')),
			end:    new \DateTimeImmutable($request->input('end')),
			doctor: Doctor::findOrFail($request->input('doctor_id')),
			// ...
		);
	} catch (RoomIsUnavailableException $e) {
		// return specific error
	} catch (DoctorIsUnavaliableException $e) {
		// return specific error
	} catch (\Throwable $e) {
		// return general error
	}

	return $appointment;
}
2 likes
vincent15000's avatar

As @glukinho said, it would be interesting to write all your private functions into a dedicated service.

Effectively a controller is only for executing functionalities called from a route. All business logic should be written in another file.

Other advice : the store function is for storing the appointment and not for checking if a room is available. You could separate the logic and check for room availability in your form request with a custom rule.

You say that it doesn't work correctly : what doesn't work ? Do you get an error ? Do you have scheduling conflicts ?

1 like
lim105's avatar

Thanks to both of you for your suggestions! 😊

After discussing with my tutor, he recommended moving the conflict-checking logic into the policy instead of creating a dedicated service, since the project is quite simple and he preferred keeping things lightweight for now.

Regarding the issue I mentioned (that it didn't work correctly): while testing on the site, I realized it allowed me to insert a new treatment even if there were conflicts—none of the if conditions were triggered. That’s why the conflict checks didn’t seem to function.

I’ve since modified both the controller and the policy according to my tutor’s advice, and I’ve also added proper error handling on the frontend (which hadn’t been implemented yet). Now everything is working as expected.

Really appreciate your input—it helped clarify some concepts and guided my debugging process

1 like
vincent15000's avatar

@lim105 That's really not a good practice.

A policy is for checking if a user is allowed to do something.

And not for checking if a room is available.

I suggest you to check availability in a form request with a custom rule.

Please or to participate in this conversation.