All writing
3 min read

Participant polymorphic: satu workflow, banyak tipe peserta

Scheduling dan attendance di School HRIS butuh peserta yang bukan cuma student. Solusinya: participant polymorphic, entity tetap terpisah.

Context

Di project School HRIS, ada workflow scheduling dan attendance. Pesertanya bukan cuma student yang sudah terdaftar. Ada juga candidate — calon siswa yang ikut placement test sebelum resmi jadi student.

Requirement-nya sederhana di atas kertas: jadwalkan placement test, catat kehadirannya. Yang bikin rumit: candidate dan student itu dua entity berbeda, dengan data berbeda, lifecycle berbeda. Tapi mereka bisa ikut schedule yang sama.

Di masa depan, kemungkinan ada tipe peserta ketiga. Mentor tamu, misalnya. Atau guru pengganti. Aku belum tahu pasti, tapi kemungkinannya besar.

Options yang dipertimbangkan

Option A: Tabel pivot per tipe peserta

schedule_students dan schedule_candidates, dua tabel terpisah.

  • Pro: foreign key constraint penuh, database enforce integritas
  • Pro: query per tipe gampang
  • Con: tiap tipe peserta baru = tabel baru + migration + model baru
  • Con: query “siapa saja peserta schedule X” harus UNION dua tabel

Option B: Satu kolom user_id, semua jadi user

Paksa candidate jadi user biasa dengan flag.

  • Pro: paling simpel di schema
  • Con: lifecycle candidate beda jauh dari student. Candidate bisa ditolak, student tidak. Maksa satu tabel = banyak kolom nullable
  • Con: ngotorin entity student dengan state yang bukan miliknya

Option C: Participant polymorphic (yang dipilih)

Satu tabel schedule_participants dengan participant_type + participant_id.

  • Pro: tipe peserta baru = zero schema change
  • Pro: query “semua peserta schedule X” = satu query
  • Con: tidak ada foreign key ke dua tabel sekaligus. Integritas dijaga di application code

Keputusan

Aku pilih Option C. Schema-nya:

Schema::create('schedule_participants', function (Blueprint $table) {
    $table->id();
    $table->foreignId('schedule_id')->constrained()->cascadeOnDelete();
    $table->string('participant_type'); // 'student' | 'candidate'
    $table->unsignedBigInteger('participant_id');
    $table->timestamps();

    $table->index(['participant_type', 'participant_id']);
    $table->unique(['schedule_id', 'participant_type', 'participant_id']);
});

Di model, pakai morphTo:

class ScheduleParticipant extends Model
{
    public function participant(): MorphTo
    {
        return $this->morphTo();
    }
}

Entity students dan candidates tetap terpisah. Tidak ada yang dipaksa merge. Yang shared cuma konsep “peserta workflow”.

Gotcha: eligibility rule

Satu aturan bisnis yang hampir lewat: candidate yang sudah punya akun yang terhubung ke student tidak boleh masuk pool candidate lagi. User dengan dua profil diperlakukan sebagai student, bukan candidate.

Kalau rule ini bocor, satu orang bisa muncul dua kali di schedule yang sama — sekali sebagai candidate, sekali sebagai student.

Fix-nya di scope query:

public function scopeEligibleAsCandidate(Builder $query): Builder
{
    return $query->whereDoesntHave('user.student');
}

Dan di UI: kalau tidak ada candidate yang eligible, kontrol “Add Candidate” disembunyikan. Jangan tampilkan dropdown kosong yang bikin user bingung.

Urutan implementasi

Scheduling dulu, attendance belakangan. Kenapa? Karena attendance bergantung pada data schedule. Tanpa schedule, tidak ada yang bisa di-absen. Urutan ini memaksa schema participant selesai dan stabil sebelum dipakai dua modul.

Consequences

  • Tambah tipe peserta baru sekarang cuma: tambah konstanta + register morph map. Tidak ada migration.
  • Query peserta per schedule jadi satu query sederhana, bukan UNION.
  • Integritas data harus dijaga di kode. Tidak ada FK yang nge-block orphan participant. Aku terima trade-off ini karena cleanup job lebih murah daripada schema rigid.
  • Ada satu helper sentral untuk resolve participant → nama/NIS/email, supaya modul lain tidak perlu tahu bedanya student dan candidate.

What I’d do differently

  • Register morph map dari hari pertama. Default morphTo menyimpan fully-qualified class name di participant_type. Kalau namespace model pindah, data lama rusak. String literal 'student' / 'candidate' lebih aman.
  • Buat helper eligibility sebagai query scope sejak awal, bukan filter inline yang tersebar di controller.

References