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

ianclemence's avatar

Issue with Checking Lesson Scheduling Conflicts in Laravel

Hi everyone,

I'm working on a Laravel application to schedule lessons for a timetable, and I'm facing an issue with checking for conflicts when scheduling new lessons. Specifically, I want to prevent the creation of new lessons that overlap in time with existing lessons for the same teacher, class, or stream. Below are the detailed scenarios I want to handle:

  1. A new lesson cannot be scheduled for the same class and stream at the same time.
  2. A new lesson cannot be scheduled with the same teacher at the same time, even for different classes and streams.
  3. The new lesson should not conflict with existing lessons for the same day and overlapping times.

Here is my current function:


// Lesson model
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;

class Lesson extends Model
{
    use HasFactory, SoftDeletes;

    const WEEK_DAYS = [
        '1' => 'Monday',
        '2' => 'Tuesday',
        '3' => 'Wednesday',
        '4' => 'Thursday',
        '5' => 'Friday',
        '6' => 'Saturday',
        '7' => 'Sunday',
    ];

    public function class()
    {
        return $this->belongsTo(Classes::class, 'classes_id');
    }

    public function stream()
    {
        return $this->belongsTo(Stream::class);
    }

    public function course()
    {
        return $this->belongsTo(Course::class);
    }

    public function teacher()
    {
        return $this->belongsTo(Teacher::class);
    }

    public function students()
    {
        return $this->hasMany(Student::class);
    }

    public static function isLessonAvailable($weekday, $startTime, $endTime, $classId, $streamId, $courseId, $teacherId, $lessonId = null)
    {
        $conflicts = self::where('weekday', $weekday)
            ->when($lessonId, function ($query) use ($lessonId) {
                return $query->where('id', '!=', $lessonId);
            })
            ->where([
                ['start_at', '<', $endTime],
                ['end_at', '>', $startTime],
            ])
            ->where(function ($query) use ($classId, $streamId, $courseId, $teacherId) {
                $query->where('classes_id', $classId)
                    ->where('stream_id', $streamId)
                    ->orWhere('course_id', $courseId)
                    ->orWhere('teacher_id', $teacherId);
            })
            ->where(function ($query) use ($teacherId, $courseId) {
                $query->where('teacher_id', $teacherId)
                    ->orWhere('course_id', $courseId);
            })
            ->where(function ($query) use ($courseId, $teacherId) {
                $query->where('course_id', $courseId)
                    ->orWhere('teacher_id', $teacherId);
            })
            ->exists();

        return $conflicts;
    }

}


// Lessons schema
Schema::create('lessons', function (Blueprint $table) {
    $table->id();
    $table->foreignId('classes_id')->constrained()->onDelete('cascade');
    $table->foreignId('stream_id')->constrained()->onDelete('cascade');
    $table->foreignId('course_id')->constrained()->onDelete('cascade');
    $table->foreignId('teacher_id')->constrained()->onDelete('cascade');
    $table->integer('weekday');
    $table->time('start_at');
    $table->time('end_at');
    $table->timestamps();
    $table->softDeletes();
});

Form Values:

  1. First Case:

    • weekday: 2 (Tuesday)
    • startTime: 09:20:00
    • endTime: 10:10:00
    • classId: 2
    • streamId: 2
    • courseId: 5
    • teacherId: 1
    • lessonId: null
  2. Second Case:

    • weekday: 1 (Monday)
    • startTime: 09:20:00
    • endTime: 10:10:00
    • classId: 2
    • streamId: 2
    • courseId: 5
    • teacherId: 1
    • lessonId: null

Existing Lessons (in the DB):

  1. Lesson 1:

    • weekday: 1 (Monday)
    • start_at: 08:30
    • end_at: 09:20
    • class_id: 1
    • stream_id: 4
    • course_id: 4
    • teacher_id: 1
  2. Lesson 2:

    • weekday: 1 (Monday)
    • start_at: 09:20
    • end_at: 10:10
    • class_id: 1
    • stream_id: 4
    • course_id: 2
    • teacher_id: 2

Despite trying to debug in every possible way I know, I'm still encountering issues where the function returns false even when there should be a conflict. Any insights or improvements on how to correctly implement this conflict checking logic would be greatly appreciated.

0 likes
2 replies
martinbean's avatar

@ianclemence Couple of things: I’d extract the week days to an enumeration:

enum DayOfWeek: int
{
    case Monday = 1;
    case Tuesday = 2;
    // And so on...
}

I’m also not 100% sure on this one, but “class” may be a reserved keyword in PHP, i.e. you may not be able to use that as your relation name. I know I had issues with a relation called “match” in one of my applications when PHP 8.0 introduced the match expression.

In answer to the actual question, you need to check two conditions:

  1. Whether the time span is free
  2. If the time span is not free, whether the teacher, class, and stream are different.

I’d also pull the method for checking this out of your Lesson model and into a dedicated class:

class Availability
{
    public function forLesson(Lesson $lesson): bool
    {
        $exists = Lesson::query()
            ->where(function (Builder $query) use ($lesson): void {
                // Check if time span is completely free
                $query->where('start_at', '>', $lesson->start_at);
                $query->where('end_at', '<', $lesson->end_at);
            })
            ->orWhere(function (Builder $query) use ($lesson): void {
                // If time span is not completely free...
                $query->where('start_at', '<=', $lesson->start_at);
                $query->where('end_at', '>=', $lesson->end_at);

                $query->where(function (Builder $query) use ($lesson): void {
                    // Check class, stream, and teacher are all free
                    $query->where('class_id', '=', $lesson->class_id);
                    $query->orWhere('stream_id', '=', $lesson->stream_id);
                    $query->orWhere('teacher_id', '=', $lesson->teacher_id);
                });
            })
            ->exists();

        if ($exists) {
            throw new UnavailableException();
        }
    }
}

(Disclaimer: code may be wrong. It was written off the cuff, but should illustrate indentation and usage.)

You’ll notice I just pass a Lesson model instance instead of individual parameters. The idea would be, you pass a Lesson model instance to see if the query is “satisfied”. You could do this in a transaction where you create your lessons:

DB::transaction(function () use ($request, $availability) {
    $lesson = Lesson::query()->create($request->validated()); // For brevity

    $availability->forLesson($lesson);
});

If there’s a conflict with availability, then an UnavailableException instance will be thrown, the transaction cancelled, and the model won’t be persisted to the database.

By passing an entire Lesson model instance, it also means you don‘t need to change the arguments if you want to change the conditions being checked.

ianclemence's avatar
ianclemence
OP
Best Answer
Level 3

I found that there was an issue with the way I was implementing my queries, therefore I refactored them and now they work as intended. Thank you for your swift response.

Updated function:

public static function isLessonAvailable($weekday, $startTime, $endTime, $classId, $streamId, $teacherId, $lessonId = null)
    {
        $conflicts = self::where('weekday', $weekday)
            ->when($lessonId, function ($query) use ($lessonId) {
                return $query->where('id', '!=', $lessonId);
            })
            ->where(function ($query) use ($startTime, $endTime) {
                $query->whereBetween('start_at', [$startTime, $endTime])
                    ->orWhereBetween('end_at', [$startTime, $endTime])
                    ->orWhere(function ($query) use ($startTime, $endTime) {
                        $query->where('start_at', '<', $startTime)
                            ->where('end_at', '>', $endTime);
                    });
            })
            ->where(function ($query) use ($classId, $streamId, $teacherId) {
                $query->where('classes_id', $classId)
                    ->orWhere('stream_id', $streamId)
                    ->orWhere('teacher_id', $teacherId);
            })
            ->exists();

        return $conflicts;
    }

Please or to participate in this conversation.