Membuat Website dari 0 Sampai Online dengan PHP MySQL: Form OSIS Lengkap + Login Admin

| | | PHP
Membuat Website dari 0 Sampai Online dengan PHP MySQL: Form OSIS Lengkap + Login Admin

membuat website dari nol sampai benar-benar bisa dibuka dari HP teman kalian adalah momen paling nagih. di panduan ini saya tidak bikin landing page statis lagi, tapi website dinamis PHP + MySQL yang datanya disimpan beneran: form pendataan anggota OSIS / organisasi masyarakat / karang taruna lengkap, lalu admin bisa login untuk edit, hapus, approve, dan kasih nomor anggota otomatis OSIS-2026-0001.

banyak pemula bingung: input type="text" sama type="number" bedanya apa di database? checkbox yang bisa pilih banyak itu kok jadi array? select dropdown vs select multiple vs datalist itu bedanya di mana? di artikel ini saya bedah semua tipe input yang ada di HTML + tipe datanya di PHP + tipe kolom MySQL-nya, langsung pakai studi kasus nyata yang bisa kalian publish.

Daftar Isi

1. Konsep Dasar: Website Dinamis PHP + MySQL

kalau di artikel sebelumnya kita hanya punya index.html + style.css yang isinya statis, sekarang alurnya bertambah satu lapisan:

flowchart LR
    A["Browser Pengunjung<br/>Form HTML 24 Input"] -- "isi form<br/>POST + FILES" --> B["Server<br/>Apache + PHP<br/>proses_daftar.php"]
    B -- "simpan / baca<br/>mysqli prepare" --> C[("Database MySQL<br/>anggota + users")]
    C -- "tampilkan HTML + data" --> A

    style A fill:#eef4ff,stroke:#000,stroke-width:3px
    style B fill:#f0eefc,stroke:#000,stroke-width:3px
    style C fill:#e6f7fb,stroke:#000,stroke-width:3px
  1. Client (Browser): menampilkan form HTML. Setiap <input> punya type dan name yang menentukan data apa yang dikirim.
  2. Server (PHP): menangkap $_POST / $_FILES, validasi, lalu simpan ke MySQL lewat mysqli/PDO.
  3. Database (MySQL): menyimpan permanen. Tiap kolom punya tipe data (VARCHAR, INT, DATE, JSON) yang harus cocok dengan tipe input HTML.
  4. Hosting: komputer server di internet yang menjalankan Apache + PHP + MySQL 24 jam. Domain mengarah ke hosting itu.

Kenapa harus PHP + MySQL untuk OSIS? Karena datanya relational: satu anggota punya banyak hobi, satu admin mengelola banyak anggota, butuh login, approve, dan nomor anggota. Ini tidak bisa pakai HTML statis.

2. Persiapan Environment Laragon + VS Code

kalian butuh server lokal, bukan Live Server lagi.

  1. Instal Laragon (rekomendasi 2026) dari laragon.org - centang Apache, PHP 8.3, MySQL 8.4. Alternatif: XAMPP.
  2. Instal VS Code + ekstensi PHP Intelephense.
  3. Buka Laragon → Klik Start All → buka http://localhost untuk cek.
  4. Buat folder project di C:\laragon\www\osis-app → buka di VS Code.
  5. Buat database: klik Database di Laragon → HeidiSQL terbuka → buat database organisasi_osis charset utf8mb4.

Struktur folder akhir (tanpa CSS eksternal — cukup index.php saja):

osis-app/
├── config.php
├── index.php            // form pendaftaran publik (sudah include <style> minimal)
├── proses_daftar.php
├── login.php            // pakai <style> yang sama, copy dari index.php
├── dashboard.php        // tabel native + 5 baris CSS opsional
├── edit.php
├── hapus.php
├── approve.php
├── logout.php
└── uploads/             // foto anggota ( Dibuat otomatis via mkdir )

assets/style.css tidak wajib. Semua UI di artikel ini pakai <style> 18 baris di dalam <head> — hapus pun tetap jalan karena pakai elemen HTML bawaan.

3. Desain Database: Tabel Admin dan Anggota

buka tab SQL di HeidiSQL/phpMyAdmin, jalankan ini:

CREATE DATABASE IF NOT EXISTS organisasi_osis CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
USE organisasi_osis;

-- Tabel admin untuk login
CREATE TABLE users (
 id INT AUTO_INCREMENT PRIMARY KEY,
 username VARCHAR(50) UNIQUE NOT NULL,
 password VARCHAR(255) NOT NULL, -- simpan hash, bukan plain text
 nama_lengkap VARCHAR(100),
 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;

-- Admin default: username admin / password admin123
INSERT INTO users (username, password, nama_lengkap) VALUES 
('admin', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'Admin OSIS');

-- Tabel anggota: menampung SEMUA tipe input
CREATE TABLE anggota (
 id INT AUTO_INCREMENT PRIMARY KEY,
 nomor_anggota VARCHAR(20) UNIQUE, -- OSIS-2026-0001, diisi saat approve
 nama_lengkap VARCHAR(100) NOT NULL,
 nis VARCHAR(20) NOT NULL,
 kelas VARCHAR(50), -- dari datalist
 jenis_kelamin ENUM('L','P') NOT NULL, -- dari radio
 agama VARCHAR(20), -- dari select dropdown
 tanggal_lahir DATE, -- dari input date
 waktu_wawancara TIME, -- dari input time
 jadwal_wawancara DATETIME, -- dari datetime-local
 bulan_angkatan VARCHAR(7), -- dari month: 2026-09
 minggu_orientasi VARCHAR(8), -- dari week: 2026-W38
 email VARCHAR(100),
 no_hp VARCHAR(20), -- tel, simpan sebagai VARCHAR biar 08 tidak hilang
 instagram VARCHAR(100), -- url
 alamat TEXT, -- textarea
 warna_kartu VARCHAR(7), -- color: #2563eb
 tingkat_keaktifan TINYINT, -- range 1-10
 hobi JSON, -- checkbox array: ["Futsal","Coding"]
 ekskul JSON, -- select multiple array: ["Paskibra","PMR"]
 jabatan_diminati VARCHAR(50), -- select
 motivasi TEXT,
 referensi VARCHAR(100), -- search
 foto VARCHAR(255), -- file path
 status ENUM('pending','approved','rejected') DEFAULT 'pending',
 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;

Penjelasan Detail Per Baris SQL

bayangkan database itu lemari besar, tabel itu laci, kolom itu sekat di dalam laci.

BarisKodeArtinya untuk Pemula
1CREATE DATABASE IF NOT EXISTS organisasi_osis ... utf8mb4Bikin lemari baru namanya organisasi_osis. IF NOT EXISTS = kalau lemarinya sudah ada, jangan bikin lagi biar tidak error. utf8mb4 = biar bisa simpan emoji & huruf Indonesia lengkap.
2USE organisasi_osis;Buka lemarinya. Semua perintah setelah ini dikerjakan di dalam lemari itu.
3CREATE TABLE users (...)Bikin laci pertama namanya users untuk simpan data admin yang bisa login.
4id INT AUTO_INCREMENT PRIMARY KEYid = nomor urut laci. INT = angka. AUTO_INCREMENT = nomornya nambah sendiri 1,2,3 otomatis. PRIMARY KEY = KTP-nya laci, tidak boleh kembar.
5username VARCHAR(50) UNIQUE NOT NULLusername = nama login. VARCHAR(50) = teks pendek max 50 huruf. UNIQUE = tidak boleh ada dua admin dengan username sama. NOT NULL = wajib diisi, tidak boleh kosong.
6password VARCHAR(255) NOT NULLTempat simpan password yang sudah diacak (hash). 255 sengaja panjang karena hash itu panjang. Jangan pernah simpan password asli!
7created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMPStempel waktu otomatis: kapan baris ini dibuat. DEFAULT CURRENT_TIMESTAMP = kalau tidak diisi, MySQL isi dengan jam sekarang otomatis.
8INSERT INTO users VALUES ('admin', '$2y$10$...', 'Admin OSIS')Masukkan 1 admin pertama. Password admin123 sudah diacak jadi $2y$10$... (hasil password_hash). Ibarat kunci gembok yang sudah dikunci.
9CREATE TABLE anggota (...)Bikin laci kedua namanya anggota — ini brankas utama semua pendaftar OSIS.
10nomor_anggota VARCHAR(20) UNIQUENomor anggota seperti OSIS-2026-0001. UNIQUE biar tidak ada yang kembar. Kosong dulu saat daftar, baru diisi saat admin klik Approve.
11jenis_kelamin ENUM('L','P')ENUM = pilihan yang sudah dikunci cuma boleh L atau P. Kalau isi X akan ditolak. Ibarat soal pilihan ganda yang cuma ada 2 jawaban.
12tanggal_lahir DATETipe kalender. Format wajib 2026-09-24. Cocok untuk <input type="date">.
13hobi JSON dan ekskul JSONJSON = laci yang bisa simpan banyak barang sekaligus: ["Futsal","Coding"]. Cocok untuk checkbox[] dan select multiple yang pilihnya bisa banyak. MySQL 8+ paham JSON.
14no_hp VARCHAR(20) bukan INTNomor HP 0812... kalau pakai INT angka 0 depannya hilang jadi 812.... Makanya pakai VARCHAR biar 0-nya tetap.
15status ENUM('pending','approved','rejected') DEFAULT 'pending'Status pendaftaran. Default pending = menunggu verifikasi. Nanti admin ubah jadi approved. Ibarat stempel: belum diperiksa → lolos.

4. Daftar Lengkap Tipe Input HTML dan Tipe Datanya

ini bagian paling penting yang kalian minta. semua input di HTML itu string di awal, tapi di PHP dan MySQL kita bedakan cara simpan dan validasinya.

#Tipe Input HTMLContoh KodeData di PHP $_POSTTipe Kolom MySQLCatatan Pemula
1text<input type="text" name="nama">string "Budi"VARCHAR(100)Nama, NIS, kelas
2email<input type="email" name="email">string "[email protected]"VARCHAR(100)Browser validasi @ otomatis
3password<input type="password" name="pass">stringVARCHAR(255) hashWajib password_hash()
4number<input type="number" name="umur">string "17"(int)INT / TINYINTSelalu cast ke int
5tel<input type="tel" name="no_hp">string "081234"VARCHAR(20)Jangan INT, angka 0 depan hilang
6url<input type="url" name="instagram">string "https://..."VARCHAR(100)Validasi filter_var()
7search<input type="search" name="referensi">stringVARCHAR(100)Sama dengan text, ada icon X
8textarea<textarea name="alamat">string panjangTEXTUntuk alamat/motivasi
9radio<input type="radio" name="jk" value="L">string "L"ENUM('L','P')1 pilihan, name sama
10checkbox tunggal<input type="checkbox" name="setuju" value="1">isset"1"TINYINT(1)Centang/tidak
11checkbox array<input type="checkbox" name="hobi[]" value="Futsal">array ["Futsal","Coding"]JSONWAJIB [] di name
12select dropdown<select name="agama"><option>Islamstring "Islam"VARCHAR(20)1 pilihan
13select multiple<select name="ekskul[]" multiple>arrayJSONWAJIB [] + multiple
14datalist<input list="kelasList" name="kelas"><datalist id="kelasList">stringVARCHAR(50)Kombinasi text + dropdown saran
15date<input type="date" name="tgl_lahir">string "2008-09-24"DATEFormat YYYY-MM-DD
16time<input type="time" name="waktu">string "14:30"TIMEJam:menit
17datetime-local<input type="datetime-local" name="jadwal">string "2026-09-24T14:30"DATETIMEGanti T jadi spasi saat simpan
18month<input type="month" name="bulan">string "2026-09"VARCHAR(7)Bulan angkatan
19week<input type="week" name="minggu">string "2026-W38"VARCHAR(8)Minggu orientasi
20color<input type="color" name="warna">string "#2563eb"VARCHAR(7)Picker warna kartu
21range<input type="range" min="1" max="10" name="aktif">string "8"TINYINTSlider keaktifan
22file<input type="file" name="foto">$_FILESVARCHAR(255) pathSimpan path, file di uploads/
23hidden<input type="hidden" name="id" value="5">stringINTUntuk edit, jangan percaya user
24hidden CSRF<input type="hidden" name="csrf">string-Keamanan anti tembak form

5. Membuat Form Pendaftaran Anggota OSIS Komplit

buat file index.php - ini halaman publik yang bisa diisi siapa saja. saya sudah masukkan semua 24 tipe input di atas dalam satu form studi kasus OSIS.

<!-- index.php — tanpa CSS berat, cuma 18 baris style biar jelas pas php -S -->
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Daftar Anggota OSIS - SMP Negeri 1</title>
<style>
  body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;max-width:720px;margin:24px auto;padding:0 16px;line-height:1.5;color:#111}
  h1{margin:0 0 4px;font-size:22px} p.muted{color:#555;margin:0 0 16px}
  fieldset{border:1px solid #ccc;padding:12px 16px;margin:16px 0} legend{font-weight:700;padding:0 6px}
  label{display:block;margin:10px 0} input,select,textarea{width:100%;padding:8px;margin-top:4px;box-sizing:border-box;border:1px solid #999}
  input[type=radio],input[type=checkbox],input[type=color],input[type=range]{width:auto}
  .row{display:grid;grid-template-columns:1fr 1fr;gap:12px} @media(max-width:600px){.row{grid-template-columns:1fr}}
  button{padding:10px 16px;border:1px solid #000;background:#fff;cursor:pointer} button[type=submit]{background:#111;color:#fff}
  small{color:#666} .req{color:#c00}
</style>
</head>
<body>
<h1>Form Pendaftaran Anggota OSIS 2026/2027</h1>
<p class="muted">Isi jujur. Setelah kirim, admin verifikasi & beri nomor anggota <code>OSIS-2026-0001</code>.</p>

<form action="proses_daftar.php" method="POST" enctype="multipart/form-data">
  <fieldset>
    <legend>Data Pribadi</legend>
    <label>Nama Lengkap <span class="req">*</span> <input type="text" name="nama_lengkap" required placeholder="Budi Santoso"></label>
    <label>NIS <span class="req">*</span> <input type="text" name="nis" required pattern="[0-9]{4,20}" placeholder="12345"></label>
    <label>Kelas <span class="req">*</span>
      <input list="kelasList" name="kelas" required placeholder="XII IPA 1">
      <datalist id="kelasList"><option value="X IPA 1"><option value="XI IPA 1"><option value="XII IPA 1"><option value="XII IPS 1"></datalist>
    </label>
    <fieldset>
      <legend>Jenis Kelamin <span class="req">*</span></legend>
      <label><input type="radio" name="jenis_kelamin" value="L" required> Laki-laki</label>
      <label><input type="radio" name="jenis_kelamin" value="P"> Perempuan</label>
    </fieldset>
    <label>Agama <span class="req">*</span>
      <select name="agama" required><option value="">-- Pilih --</option><option>Islam</option><option>Kristen</option><option>Katolik</option><option>Hindu</option><option>Buddha</option><option>Konghucu</option></select>
    </label>
  </fieldset>

  <fieldset>
    <legend>Waktu</legend>
    <div class="row">
      <label>Tanggal Lahir <span class="req">*</span> <input type="date" name="tanggal_lahir" required></label>
      <label>Waktu Wawancara <input type="time" name="waktu_wawancara"></label>
    </div>
    <label>Jadwal Lengkap <input type="datetime-local" name="jadwal_wawancara"></label>
    <div class="row">
      <label>Bulan Angkatan <input type="month" name="bulan_angkatan" value="2026-09"></label>
      <label>Minggu Orientasi <input type="week" name="minggu_orientasi"></label>
    </div>
  </fieldset>

  <fieldset>
    <legend>Kontak & Sosial</legend>
    <label>Email <span class="req">*</span> <input type="email" name="email" required placeholder="[email protected]"></label>
    <label>No HP/WA <span class="req">*</span> <input type="tel" name="no_hp" required placeholder="08123456789"></label>
    <label>Instagram <input type="url" name="instagram" placeholder="https://instagram.com/username"></label>
    <label>Dari mana tahu OSIS? <input type="search" name="referensi" placeholder="Instagram / Guru"></label>
  </fieldset>

  <fieldset>
    <legend>Preferensi</legend>
    <div class="row">
      <label>Umur <input type="number" name="umur" min="12" max="25" placeholder="17"></label>
      <label>Warna Kartu <input type="color" name="warna_kartu" value="#2563eb"></label>
    </div>
    <label>Tingkat Keaktifan (1-10) <input type="range" name="tingkat_keaktifan" min="1" max="10" value="5" oninput="this.nextElementSibling.value=this.value"> <output>5</output></label>
  </fieldset>

  <fieldset>
    <legend>Hobi <span class="req">*</span> — boleh banyak (array)</legend>
    <label><input type="checkbox" name="hobi[]" value="Futsal"> Futsal</label>
    <label><input type="checkbox" name="hobi[]" value="Coding"> Coding</label>
    <label><input type="checkbox" name="hobi[]" value="Desain"> Desain</label>
    <label><input type="checkbox" name="hobi[]" value="Musik"> Musik</label>
    <label><input type="checkbox" name="hobi[]" value="Basket"> Basket</label>
  </fieldset>

  <label>Ekstrakurikuler (Ctrl+Klik untuk banyak — array)
    <select name="ekskul[]" multiple size="4"><option>Paskibra</option><option>PMR</option><option>Pramuka</option><option>Rohis</option></select>
  </label>

  <label>Jabatan Diminati
    <select name="jabatan_diminati"><option value="">-- Pilih --</option><option>Ketua</option><option>Sekretaris</option><option>Bendahara</option><option>Humas</option><option>Anggota</option></select>
  </label>

  <label>Alamat Lengkap <span class="req">*</span> <textarea name="alamat" rows="3" required placeholder="Jl. Mawar No 10..."></textarea></label>
  <label>Motivasi <span class="req">*</span> <textarea name="motivasi" rows="4" required></textarea></label>
  <label>Foto 3x4 (JPG/PNG max 2MB) <input type="file" name="foto" accept=".jpg,.jpeg,.png"></label>

  <input type="hidden" name="csrf" value="osis2026">
  <p><button type="submit">Kirim Pendaftaran</button> <button type="reset">Reset</button> <small><span class="req">*</span> wajib</small></p>
</form>
<p><small>Tanpa <code>assets/style.css</code> — semua style di &lt;style&gt; atas. Hapus &lt;style&gt; pun form tetap jalan karena pakai HTML bawaan browser.</small></p>
</body>
</html>

Penjelasan Detail Per Baris HTML

BarisKodeArtinya untuk Pemula
1<form action="proses_daftar.php" method="POST" enctype="multipart/form-data">Amplop pengiriman. action = alamat tujuan (file PHP yang menerima). method="POST" = kirimnya diam-diam (tidak terlihat di URL, aman untuk data banyak). enctype="multipart/form-data" = wajib kalau ada upload file, kalau lupa fotonya tidak akan kekirim. Ibarat amplop khusus yang bisa bawa foto.
2<input type="text" name="nama_lengkap" required>Kotak isian teks biasa. name="nama_lengkap" = label di amplop, nanti PHP baca $_POST['nama_lengkap']. required = tidak boleh kosong, browser akan marah kalau dikosongkan.
3pattern="[0-9]{4,20}" di NISPolisi format: cuma boleh angka 0-9, panjang 4-20 digit. Kalau isi huruf, browser tolak sebelum dikirim.
4<input list="kelasList" name="kelas"> + <datalist>Kombinasi text + saran dropdown. User boleh ketik bebas XII IPA 1 atau pilih saran. Beda dengan <select> yang harus pilih dari daftar. Ibarat Google: ketik, muncul saran.
5<input type="radio" name="jenis_kelamin" value="L">Pilihan cuma 1. Semua radio yang name-nya sama (jenis_kelamin) jadi satu grup. Kalau pilih L, yang P otomatis lepas. Ibarat pilih ketua kelas: cuma bisa 1.
6<select name="agama">Dropdown 1 pilihan. Klik panah, muncul daftar. Beda dengan radio yang langsung terlihat semua. Cocok kalau pilihannya 6+.
7<input type="date">Kalender mini. User klik, muncul kalender. Hasilnya selalu 2026-09-24 (tahun-bulan-tanggal) biar MySQL langsung paham.
8<input type="checkbox" name="hobi[]" value="Futsal">Centang banyak. [] adalah kunci: tanpa [] cuma 1 hobi terakhir yang kekirim, dengan [] semua yang dicentang jadi array ["Futsal","Coding"]. Ibarat keranjang: boleh ambil banyak.
9<select name="ekskul[]" multiple>Dropdown tapi boleh pilih banyak dengan Ctrl+Klik. Juga wajib [] + multiple. Array juga: ["Paskibra","PMR"].
10<input type="file" name="foto" accept=".jpg,.png">Tombol pilih file dari HP/laptop. accept = saring cuma boleh JPG/PNG. Nanti di PHP bacanya bukan $_POST tapi $_FILES.
11<textarea name="alamat">Kotak teks besar untuk alamat panjang. Beda dengan input text yang cuma 1 baris.
12<input type="hidden" name="csrf">Input siluman: tidak terlihat user, tapi ikut kekirim. Untuk keamanan & bawa data diam-diam. Jangan taruh nomor_anggota di sini — itu dibuat admin, bukan user!
13<input type="color">, range, month, weekInput cantik: color = palet warna, range = slider geser, month = bulan-tahun, week = minggu ke-berapa. Semua hasilnya tetap string di PHP, tinggal simpan ke VARCHAR.

6. Proses Simpan ke MySQL: Semua Tipe Data Masuk Database

buat 2 file: config.php untuk koneksi dan proses_daftar.php untuk menangkap form.

<?php
// config.php - koneksi aman
$host = "localhost";
$user = "root";
$pass = "";
$db  = "organisasi_osis";
$conn = new mysqli($host, $user, $pass, $db);
if ($conn->connect_error) die("Koneksi gagal: " . $conn->connect_error);
$conn->set_charset("utf8mb4");
?>

Penjelasan Detail Per Baris config.php

BarisKodeArtinya
1$host = "localhost";Alamat rumah database. localhost = database ada di laptop sendiri. Nanti di hosting ganti jadi sql123.infinityfree.com.
2$user = "root";Username database. Di Laragon default-nya root tanpa password — seperti akun default.
3$pass = "";Password database. Kosong di lokal, wajib isi di hosting.
4$db = "organisasi_osis";Nama lemari yang mau dibuka. Harus sudah dibikin di HeidiSQL tadi.
5new mysqli($host, $user, $pass, $db)Buka koneksi database pakai 4 data itu (host, user, pass, nama database). Hasilnya simpan di $conn (connection = sambungan).
6if ($conn->connect_error) die(...)Kalau data koneksi salah, hentikan dan tampilkan “Koneksi gagal”. Biar tidak lanjut dengan sambungan rusak.
7set_charset("utf8mb4")Atur bahasa database biar paham huruf Indonesia, emoji, dll. Kalau lupa, nama Siti ❤️ bisa jadi ???.
<?php
// proses_daftar.php
require "config.php";

// 1. Ambil data STRING: wajib trim + htmlspecialchars biar aman XSS
$nama  = htmlspecialchars(trim($_POST['nama_lengkap']));
$nis  = trim($_POST['nis']);
$kelas = trim($_POST['kelas']);
$jk   = $_POST['jenis_kelamin']; // L/P
$agama = $_POST['agama'];
$email = filter_var($_POST['email'], FILTER_VALIDATE_EMAIL);
$no_hp = trim($_POST['no_hp']);
$instagram = trim($_POST['instagram']);
$alamat = htmlspecialchars(trim($_POST['alamat']));
$motivasi = htmlspecialchars(trim($_POST['motivasi']));
$referensi = trim($_POST['referensi']);
$jabatan = $_POST['jabatan_diminati'];
$warna = $_POST['warna_kartu']; // #2563eb
$aktif = (int) $_POST['tingkat_keaktifan'];

// 2. WAKTU: validasi format
$tgl_lahir = $_POST['tanggal_lahir']; // 2026-09-24 -> langsung masuk DATE
$waktu = $_POST['waktu_wawancara'] ?: null; // 14:30 -> TIME
$jadwal = $_POST['jadwal_wawancara']; // 2026-09-24T14:30 -> ganti T jadi spasi
$jadwal = $jadwal ? str_replace("T", " ", $jadwal) . ":00" : null; // jadi DATETIME
$bulan = $_POST['bulan_angkatan']; // 2026-09
$minggu = $_POST['minggu_orientasi']; // 2026-W38

// 3. ARRAY: checkbox dan select multiple -> JSON
$hobi = isset($_POST['hobi']) ? json_encode($_POST['hobi'], JSON_UNESCAPED_UNICODE) : json_encode([]);
$ekskul = isset($_POST['ekskul']) ? json_encode($_POST['ekskul'], JSON_UNESCAPED_UNICODE) : json_encode([]);

// 4. FILE: upload foto
$foto_path = null;
if (isset($_FILES['foto']) && $_FILES['foto']['error'] == 0) {
  $ext = strtolower(pathinfo($_FILES['foto']['name'], PATHINFO_EXTENSION));
  $allowed = ['jpg','jpeg','png'];
  if (!in_array($ext, $allowed)) die("Format foto harus JPG/PNG");
  if ($_FILES['foto']['size'] > 2*1024*1024) die("Foto max 2MB");
  $nama_file = "foto_" . time() . "_" . uniqid() . "." . $ext;
  $tujuan = "uploads/" . $nama_file;
  if (!is_dir("uploads")) mkdir("uploads", 0755, true);
  move_uploaded_file($_FILES['foto']['tmp_name'], $tujuan);
  $foto_path = $tujuan;
}

// 5. Simpan pakai PREPARED STATEMENT (anti SQL Injection)
$stmt = $conn->prepare("INSERT INTO anggota 
(nama_lengkap, nis, kelas, jenis_kelamin, agama, tanggal_lahir, waktu_wawancara, jadwal_wawancara, bulan_angkatan, minggu_orientasi, email, no_hp, instagram, alamat, warna_kartu, tingkat_keaktifan, hobi, ekskul, jabatan_diminati, motivasi, referensi, foto, status)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, 'pending')");

$stmt->bind_param("sssssssssssssssissssss",
 $nama, $nis, $kelas, $jk, $agama, $tgl_lahir, $waktu, $jadwal, $bulan, $minggu, $email, $no_hp, $instagram, $alamat, $warna, $aktif, $hobi, $ekskul, $jabatan, $motivasi, $referensi, $foto_path
);

if ($stmt->execute()) {
  echo "Pendaftaran berhasil! Menunggu verifikasi admin. <a href='index.php'>Kembali</a>";
} else {
  echo "Gagal: " . $stmt->error;
}
?>

Penjelasan Detail Per Baris proses_daftar.php

BlokBaris KodeArtinya untuk Pemula
Koneksi Databaserequire "config.php";Panggil file koneksi database. Tanpa file ini, PHP tidak tahu harus simpan ke database mana.
STRINGtrim($_POST['nama_lengkap'])$_POST = amplop yang dikirim form. trim() = gunting spasi kosong di depan/belakang " Budi " jadi "Budi" biar rapi.
htmlspecialchars(...)Ubah karakter jahat <script> jadi teks biasa &lt;script&gt; biar tidak bisa bobol website. Ibarat sensor: coret-coretan jahat jadi tidak berbahaya.
filter_var($_POST['email'], FILTER_VALIDATE_EMAIL)Polisi email: cek apakah ada @ dan .com. Kalau budi@ tanpa belakang, ditolak jadi false.
(int) $_POST['tingkat_keaktifan']Paksa jadi angka. "5" (string) jadi 5 (integer). Biar MySQL kolom TINYINT tidak bingung. Ibarat ubah tulisan “lima” jadi angka 5.
WAKTU$waktu = $_POST['waktu_wawancara'] ?: null;?: null = kalau user kosongkan jam wawancara, simpan null (kosong) bukan error. Ibarat “kalau tidak isi, ya kosong saja, jangan marah”.
str_replace("T", " ", $jadwal)datetime-local kirim 2026-09-24T14:30 pakai huruf T di tengah. MySQL maunya spasi 2026-09-24 14:30:00. Jadi kita ganti T jadi spasi, tambah :00 detik. Ibarat terjemahkan bahasa browser ke bahasa MySQL.
ARRAYisset($_POST['hobi']) ? json_encode(...) : json_encode([])Cek dulu: apakah user centang hobi? isset = apakah ada? Kalau ada, json_encode ubah ["Futsal","Coding"] jadi teks '"[\"Futsal\",\"Coding\"]"' biar muat 1 kolom. Kalau tidak centang sama sekali, simpan [] (keranjang kosong) biar tidak error Undefined array key.
JSON_UNESCAPED_UNICODEBiar huruf Indonesia é tidak jadi kode aneh \u00e9. Tetap cantik.
FILE$_FILES['foto']['error'] == 0$_FILES = amplop khusus foto (beda dengan $_POST). error==0 = tidak ada error, foto berhasil di-upload sementara. Kalau 4 = tidak pilih foto.
pathinfo(..., PATHINFO_EXTENSION)Ambil ekstensi file: foto.jpgjpg. Ibarat lihat buntut nama file.
strtolower(...)Ubah JPG jadi jpg biar pengecekan tidak salah gara-gara huruf besar.
if (!in_array($ext, $allowed)) die(...)Satpam: kalau bukan jpg/jpeg/png, tolak! Ibarat “selain 3 kartu itu, tidak boleh masuk”.
if ($_FILES['foto']['size'] > 2*1024*1024)Cek ukuran: 2*1024*1024 = 2 MB. Kalau foto 5 MB, tolak biar hosting tidak penuh.
time() . "_" . uniqid()Bikin nama file unik: foto_1727170000_abc123.jpg. Kenapa? Kalau dua orang upload foto.jpg, tidak tabrakan. Ibarat kasih cap waktu + kode unik.
mkdir("uploads", 0755, true)Kalau folder uploads belum ada, bikin dulu. 0755 = izin baca-tulis.
move_uploaded_file(tmp_name, tujuan)Pindahkan foto dari tempat sementara (tmp) ke folder uploads/ permanen. Ibarat pindahkan foto dari meja resepsionis ke lemari arsip.
SIMPAN$conn->prepare("INSERT INTO anggota (...) VALUES (?,?,?,...,'pending')")Siapkan lubang ? sejumlah kolom. ? = tempat kosong yang nanti diisi data. pending hardcode karena pendaftar baru selalu pending. Ibarat siapkan 23 lubang untuk 23 data. Kenapa ?? Biar aman dari hacker SQL Injection!
bind_param("sssssssssssssssissssss", $nama, ...)Isi tiap ? dengan data beneran. Huruf s = string, i = integer. Urutannya harus pas! 22 s + 1 i (untuk tingkat_keaktifan yang integer) + 6 s. Kalau salah urut, data nyasar.
$stmt->execute()Eksekusi! Masukkan data ke laci anggota. Kalau berhasil, true.
echo "Pendaftaran berhasil!"Kasih tahu user: “Oke, data kamu masuk, tunggu admin approve ya!”

7. Login Admin + Dashboard Approve Edit Delete

ini yang bikin website kalian jadi sistem beneran, bukan form doang.

7.1 Login Admin (login.php)

<?php
session_start();
require "config.php";
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
  $u = $_POST['username'];
  $p = $_POST['password'];
  $stmt = $conn->prepare("SELECT id, password, nama_lengkap FROM users WHERE username=?");
  $stmt->bind_param("s", $u);
  $stmt->execute();
  $res = $stmt->get_result()->fetch_assoc();
  if ($res && password_verify($p, $res['password'])) {
    $_SESSION['admin'] = $res['id'];
    $_SESSION['nama'] = $res['nama_lengkap'];
    header("Location: dashboard.php"); exit;
  } else {
    $err = "Username atau password salah!";
  }
}
?>
<!DOCTYPE html><html lang="id"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Login Admin OSIS</title>
<style>body{font-family:system-ui,sans-serif;max-width:480px;margin:40px auto;padding:0 16px} input,button{width:100%;padding:10px;margin:8px 0;box-sizing:border-box;border:1px solid #999} button{background:#111;color:#fff;cursor:pointer}</style>
</head><body>
<h2>Login Admin OSIS</h2>
<?php if(isset($err)) echo "<p style='color:red'>$err</p>"; ?>
<form method="POST">
 <input type="text" name="username" placeholder="admin" required>
 <input type="password" name="password" placeholder="admin123" required>
 <button type="submit">Login</button>
</form>
<p><small>Demo: admin / admin123</small></p>
</body></html>

Penjelasan Detail Per Baris login.php

BarisKodeArtinya
1session_start();Nyalakan sesi. session = gelang tanda “sudah login”. Wajib di baris paling atas sebelum HTML apapun. Ibarat nyalakan lampu pos satpam.
2if ($_SERVER['REQUEST_METHOD'] == 'POST')Cek: apakah user baru saja klik tombol Login? POST = habis submit form. Kalau baru buka halaman (GET), jangan cek password dulu.
3$stmt = $conn->prepare("SELECT ... WHERE username=?")Cari admin dengan username yang diketik. Pakai ? biar aman. Ibarat “cari laci users yang label usernamenya sama dengan yang diketik”.
4$res = $stmt->get_result()->fetch_assoc();Ambil hasilnya. fetch_assoc = ambil 1 baris jadi array: ['id'=>1, 'password'=>'$2y$10$...', 'nama_lengkap'=>'Admin OSIS']. Kalau username tidak ada, $res = null.
5password_verify($p, $res['password'])Cocokkan password ketikan vs hash di DB. password_verify("admin123", "$2y$10$...") = true. Jangan pernah bandingkan pakai ==! Hash tidak bisa dibaca balik.
6$_SESSION['admin'] = $res['id'];Kalau cocok, kasih gelang: simpan id admin ke sesi. Mulai sekarang, tiap buka dashboard.php, satpam cek isset($_SESSION['admin']) untuk tahu sudah login.
7header("Location: dashboard.php"); exit;Tendang user ke dashboard. exit wajib biar kode di bawah tidak jalan lagi. Ibarat “silakan masuk, pintu lain tutup”.
8else { $err = ... }Kalau password salah, simpan pesan error untuk ditampilkan di HTML.

7.2 Dashboard (dashboard.php)

<?php
session_start();
if (!isset($_SESSION['admin'])) { header("Location: login.php"); exit; }
require "config.php";
$data = $conn->query("SELECT * FROM anggota ORDER BY created_at DESC");
?>
<!DOCTYPE html><html lang="id"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Dashboard OSIS</title>
<style>body{font-family:system-ui,sans-serif;max-width:960px;margin:24px auto;padding:0 16px} table{width:100%;border-collapse:collapse;margin-top:12px} th,td{border:1px solid #999;padding:8px;text-align:left} th{background:#f2f2f2} a{color:#111}</style>
</head><body>
<h2>Halo, <?= htmlspecialchars($_SESSION['nama']) ?> - Dashboard Anggota</h2>
<a href="logout.php">Logout</a>
<table>
<tr><th>No Anggota</th><th>Nama</th><th>Kelas</th><th>Hobi</th><th>Status</th><th>Aksi</th></tr>
<?php while($r = $data->fetch_assoc()): ?>
<tr>
 <td><?= $r['nomor_anggota'] ?: '-' ?></td>
 <td><?= htmlspecialchars($r['nama_lengkap']) ?></td>
 <td><?= htmlspecialchars($r['kelas']) ?></td>
 <td><?= implode(", ", json_decode($r['hobi'], true) ?: []) ?></td>
 <td><?= $r['status'] ?></td>
 <td>
  <?php if($r['status']=='pending'): ?>
   <a href="approve.php?id=<?= $r['id'] ?>" onclick="return confirm('Approve & beri nomor anggota?')">Approve</a> |
  <?php endif; ?>
  <a href="edit.php?id=<?= $r['id'] ?>">Edit</a> |
  <a href="hapus.php?id=<?= $r['id'] ?>" onclick="return confirm('Hapus permanen?')">Delete</a>
 </td>
</tr>
<?php endwhile; ?>
</table>
</body></html>

Penjelasan Detail Per Baris dashboard.php

BarisKodeArtinya
1if (!isset($_SESSION['admin'])) header("Location: login.php")Satpam: kalau tidak punya gelang login, usir ke halaman login! Ini yang jaga dashboard tidak bisa dibuka orang luar.
2$conn->query("SELECT * FROM anggota ORDER BY created_at DESC")Ambil semua anggota, yang terbaru di atas (DESC = descending). Ibarat buka laci anggota dan tumpahkan semua isinya ke meja, yang baru daftar di paling atas.
3while($r = $data->fetch_assoc())Loop: ambil baris satu per satu. while = selama masih ada data, buat baris <tr> baru. Ibarat “ambil kartu anggota satu per satu dari tumpukan, tempel ke papan”.
4$r['nomor_anggota'] ?: '-'?: = kalau nomor anggota kosong (belum approve), tampilkan -. Kalau sudah ada OSIS-2026-0001, tampilkan itu.
5implode(", ", json_decode($r['hobi'], true))Hobi di DB bentuk JSON '["Futsal","Coding"]'. json_decode ubah jadi array PHP ["Futsal","Coding"], lalu implode gabung jadi teks "Futsal, Coding" biar cantik di tabel. Kebalikan dari json_encode saat simpan!
6if($r['status']=='pending')Tombol Approve cuma muncul kalau status masih pending. Kalau sudah approved, tombolnya hilang. Ibarat “kalau sudah lolos, tidak perlu stempel lagi”.
7onclick="return confirm(...)"Pop-up konfirmasi browser: “Yakin mau approve/hapus?” Kalau klik Cancel, tidak jadi. Biar tidak kepencet tidak sengaja.

7.3 Approve + Generate Nomor Anggota (approve.php)

<?php
session_start();
if (!isset($_SESSION['admin'])) die("Akses ditolak");
require "config.php";
$id = (int) $_GET['id'];
// Generate nomor: OSIS-2026-0001
$tahun = date("Y");
$q = $conn->query("SELECT COUNT(*) as total FROM anggota WHERE status='approved'");
$total = $q->fetch_assoc()['total'] + 1;
$nomor = sprintf("OSIS-%s-%04d", $tahun, $total); // 4 digit padding

$stmt = $conn->prepare("UPDATE anggota SET status='approved', nomor_anggota=? WHERE id=? AND status='pending'");
$stmt->bind_param("si", $nomor, $id);
$stmt->execute();
header("Location: dashboard.php");
?>

Penjelasan Detail Per Baris approve.php

BarisKodeArtinya
1$id = (int) $_GET['id'];Ambil id dari URL approve.php?id=5. (int) paksa jadi angka biar kalau hacker kirim id=abc' OR 1=1 jadi 0 dan gagal. Ibarat saring: cuma angka boleh lewat.
2date("Y")Ambil tahun sekarang: 2026. Biar nomor anggota ada tahunnya.
3SELECT COUNT(*) as total FROM anggota WHERE status='approved'Hitung berapa anggota yang sudah approved. Misal sudah 5 orang, total=5.
4$total + 1Tambah 1 untuk anggota baru: 5+1=6. Jadi nomor urut ke-6.
5sprintf("OSIS-%s-%04d", $tahun, $total)Bikin nomor cantik. %s = tahun, %04d = angka 4 digit dengan nol depan. 6 jadi 0006, 42 jadi 0042. Hasil: OSIS-2026-0006. Ibarat mesin cap nomor otomatis.
6UPDATE anggota SET status='approved', nomor_anggota=? WHERE id=? AND status='pending'Update 1 baris: ubah status jadi approved + isi nomor. AND status='pending' = pengaman: kalau sudah approved, tidak bisa di-approve lagi (biar nomor tidak ganda).
7header("Location: dashboard.php")Balik ke dashboard. User lihat nomornya sudah muncul!

7.4 Edit Anggota (edit.php) — Full Code (Tanpa CSS Berat)

File ini paling ditakuti pemula karena harus tampilkan data lama + simpan yang baru. Kuncinya: value="..." untuk text, selected untuk select, checked untuk radio/checkbox.

<?php
// edit.php — edit data anggota (admin only)
session_start();
if (!isset($_SESSION['admin'])) { header("Location: login.php"); exit; }
require "config.php";

$id = (int) ($_GET['id'] ?? 0);
$stmt = $conn->prepare("SELECT * FROM anggota WHERE id=?");
$stmt->bind_param("i", $id);
$stmt->execute();
$r = $stmt->get_result()->fetch_assoc();
if (!$r) die("Data tidak ditemukan");

$hobi_arr = json_decode($r['hobi'], true) ?: [];
$ekskul_arr = json_decode($r['ekskul'], true) ?: [];

if ($_SERVER['REQUEST_METHOD'] == 'POST') {
  // 1. Ambil ulang semua field (sama seperti proses_daftar.php)
  $nama = htmlspecialchars(trim($_POST['nama_lengkap']));
  $nis = trim($_POST['nis']);
  $kelas = trim($_POST['kelas']);
  $jk = $_POST['jenis_kelamin'];
  $agama = $_POST['agama'];
  $tgl_lahir = $_POST['tanggal_lahir'];
  $waktu = $_POST['waktu_wawancara'] ?: null;
  $jadwal = $_POST['jadwal_wawancara'];
  $jadwal = $jadwal ? str_replace("T"," ",$jadwal).":00" : null;
  $bulan = $_POST['bulan_angkatan'];
  $minggu = $_POST['minggu_orientasi'];
  $email = filter_var($_POST['email'], FILTER_VALIDATE_EMAIL);
  $no_hp = trim($_POST['no_hp']);
  $instagram = trim($_POST['instagram']);
  $alamat = htmlspecialchars(trim($_POST['alamat']));
  $motivasi = htmlspecialchars(trim($_POST['motivasi']));
  $referensi = trim($_POST['referensi']);
  $jabatan = $_POST['jabatan_diminati'];
  $warna = $_POST['warna_kartu'];
  $aktif = (int) $_POST['tingkat_keaktifan'];
  $hobi = isset($_POST['hobi']) ? json_encode($_POST['hobi'], JSON_UNESCAPED_UNICODE) : json_encode([]);
  $ekskul = isset($_POST['ekskul']) ? json_encode($_POST['ekskul'], JSON_UNESCAPED_UNICODE) : json_encode([]);

  // 2. Foto: kalau upload baru, ganti; kalau tidak, pakai foto lama
  $foto_path = $r['foto'];
  if (isset($_FILES['foto']) && $_FILES['foto']['error']==0) {
    $ext = strtolower(pathinfo($_FILES['foto']['name'], PATHINFO_EXTENSION));
    if (!in_array($ext, ['jpg','jpeg','png'])) die("Foto harus JPG/PNG");
    if ($_FILES['foto']['size'] > 2*1024*1024) die("Foto max 2MB");
    $nama_file = "foto_".time()."_".uniqid().".".$ext;
    $tujuan = "uploads/".$nama_file;
    if (!is_dir("uploads")) mkdir("uploads",0755,true);
    move_uploaded_file($_FILES['foto']['tmp_name'], $tujuan);
    if ($r['foto'] && file_exists($r['foto'])) unlink($r['foto']); // hapus foto lama
    $foto_path = $tujuan;
  }

  // 3. Update (nomor_anggota & status tidak diubah di sini — hanya admin via approve)
  $stmt2 = $conn->prepare("UPDATE anggota SET nama_lengkap=?, nis=?, kelas=?, jenis_kelamin=?, agama=?, tanggal_lahir=?, waktu_wawancara=?, jadwal_wawancara=?, bulan_angkatan=?, minggu_orientasi=?, email=?, no_hp=?, instagram=?, alamat=?, warna_kartu=?, tingkat_keaktifan=?, hobi=?, ekskul=?, jabatan_diminati=?, motivasi=?, referensi=?, foto=? WHERE id=?");
  $stmt2->bind_param("sssssssssssssssissssssi", $nama,$nis,$kelas,$jk,$agama,$tgl_lahir,$waktu,$jadwal,$bulan,$minggu,$email,$no_hp,$instagram,$alamat,$warna,$aktif,$hobi,$ekskul,$jabatan,$motivasi,$referensi,$foto_path,$id);
  if ($stmt2->execute()) { header("Location: dashboard.php"); exit; }
  else echo "Gagal update: ".$stmt2->error;
}
?>
<!DOCTYPE html><html lang="id"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Edit Anggota — <?= htmlspecialchars($r['nama_lengkap']) ?></title>
<style>body{font-family:system-ui,sans-serif;max-width:720px;margin:24px auto;padding:0 16px} label{display:block;margin:10px 0} input,select,textarea{width:100%;padding:8px;margin-top:4px;box-sizing:border-box;border:1px solid #999} input[type=radio],input[type=checkbox],input[type=color],input[type=range]{width:auto} fieldset{border:1px solid #ccc;padding:12px;margin:12px 0} button{padding:10px 16px;border:1px solid #000;background:#111;color:#fff;cursor:pointer}</style>
</head><body>
<h2>Edit Anggota: <?= htmlspecialchars($r['nama_lengkap']) ?> <small>(<?= $r['nomor_anggota'] ?: 'Belum ada nomor' ?>)</small></h2>
<form method="POST" enctype="multipart/form-data">
  <label>Nama Lengkap <input type="text" name="nama_lengkap" value="<?= htmlspecialchars($r['nama_lengkap']) ?>" required></label>
  <label>NIS <input type="text" name="nis" value="<?= htmlspecialchars($r['nis']) ?>" required></label>
  <label>Kelas <input list="kelasList" name="kelas" value="<?= htmlspecialchars($r['kelas']) ?>" required><datalist id="kelasList"><option value="X IPA 1"><option value="XII IPA 1"><option value="XII IPS 1"></datalist></label>
  <fieldset><legend>Jenis Kelamin</legend>
    <label><input type="radio" name="jenis_kelamin" value="L" <?= $r['jenis_kelamin']=='L'?'checked':'' ?> required> Laki-laki</label>
    <label><input type="radio" name="jenis_kelamin" value="P" <?= $r['jenis_kelamin']=='P'?'checked':'' ?>> Perempuan</label>
  </fieldset>
  <label>Agama <select name="agama" required>
    <?php foreach(['Islam','Kristen','Katolik','Hindu','Buddha','Konghucu'] as $opt): ?>
      <option <?= $r['agama']==$opt?'selected':'' ?>><?= $opt ?></option>
    <?php endforeach; ?>
  </select></label>
  <label>Tanggal Lahir <input type="date" name="tanggal_lahir" value="<?= $r['tanggal_lahir'] ?>" required></label>
  <label>Waktu Wawancara <input type="time" name="waktu_wawancara" value="<?= $r['waktu_wawancara'] ?>"></label>
  <label>Jadwal Lengkap <input type="datetime-local" name="jadwal_wawancara" value="<?= $r['jadwal_wawancara'] ? str_replace(' ','T',substr($r['jadwal_wawancara'],0,16)) : '' ?>"></label>
  <label>Bulan Angkatan <input type="month" name="bulan_angkatan" value="<?= $r['bulan_angkatan'] ?>"></label>
  <label>Minggu Orientasi <input type="week" name="minggu_orientasi" value="<?= $r['minggu_orientasi'] ?>"></label>
  <label>Email <input type="email" name="email" value="<?= htmlspecialchars($r['email']) ?>" required></label>
  <label>No HP <input type="tel" name="no_hp" value="<?= htmlspecialchars($r['no_hp']) ?>" required></label>
  <label>Instagram <input type="url" name="instagram" value="<?= htmlspecialchars($r['instagram']) ?>"></label>
  <fieldset><legend>Hobi (boleh banyak)</legend>
    <?php foreach(['Futsal','Coding','Desain','Musik','Basket'] as $h): ?>
      <label><input type="checkbox" name="hobi[]" value="<?= $h ?>" <?= in_array($h,$hobi_arr)?'checked':'' ?>> <?= $h ?></label>
    <?php endforeach; ?>
  </fieldset>
  <label>Ekskul (Ctrl+Klik) <select name="ekskul[]" multiple size="4">
    <?php foreach(['Paskibra','PMR','Pramuka','Rohis'] as $e): ?>
      <option value="<?= $e ?>" <?= in_array($e,$ekskul_arr)?'selected':'' ?>><?= $e ?></option>
    <?php endforeach; ?>
  </select></label>
  <label>Alamat <textarea name="alamat" rows="3" required><?= htmlspecialchars($r['alamat']) ?></textarea></label>
  <label>Motivasi <textarea name="motivasi" rows="4" required><?= htmlspecialchars($r['motivasi']) ?></textarea></label>
  <label>Foto (kosongkan jika tidak ganti) <?php if($r['foto']): ?><br><img src="<?= htmlspecialchars($r['foto']) ?>" width="80" style="border:1px solid #000"><?php endif; ?> <input type="file" name="foto" accept=".jpg,.jpeg,.png"></label>
  <p><button type="submit">Simpan Perubahan</button> <a href="dashboard.php">Batal</a></p>
</form>
</body></html>

Penjelasan Detail Per Baris edit.php

BarisKodeArtinya untuk Pemula
1session_start(); if (!isset($_SESSION['admin']))Satpam: cuma admin boleh edit. Kalau belum login, usir ke login.php.
2$id = (int) $_GET['id']Ambil id dari URL edit.php?id=5 dan paksa jadi angka.
3SELECT * FROM anggota WHERE id=? + fetch_assoc()Ambil 1 baris data lama. $r jadi array berisi semua kolom anggota itu.
4json_decode($r['hobi'], true) ?: []Ubah JSON '["Futsal","Coding"]' jadi array PHP. ?: [] biar kalau null jadi array kosong.
5value="<?= htmlspecialchars($r['nama_lengkap']) ?>"Tampilkan data lama di input. htmlspecialchars biar " tidak rusak HTML.
6checked via $r['jenis_kelamin']=='L'?'checked':''Kalau data lama L, maka radio L dikasih checked biar nyala.
7in_array($h, $hobi_arr)?'checked':''Untuk checkbox array: kalau Futsal ada di array hobi lama, centang.
8selected via in_array($e,$ekskul_arr)Sama untuk select multiple: kalau Paskibra pernah dipilih, kasih selected.
9str_replace(' ','T', substr(...,0,16))DATETIME di DB 2026-09-24 14:30:00 harus diubah jadi 2026-09-24T14:30 untuk datetime-local.
10if ($_FILES['foto']['error']==0) { ... unlink($r['foto']) }Kalau user upload foto baru, simpan baru + hapus foto lama. Kalau tidak upload, pakai $r['foto'] lama.
11UPDATE anggota SET ... WHERE id=?Simpan perubahan. nomor_anggota tidak ikut — biar tidak berantakan, nomor hanya via approve.php.

7.5 Hapus Anggota (hapus.php) — Full Code

<?php
// hapus.php — hapus permanen + hapus file foto
session_start();
if (!isset($_SESSION['admin'])) die("Akses ditolak");
require "config.php";

$id = (int) ($_GET['id'] ?? 0);
// Ambil foto dulu biar bisa dihapus dari folder
$stmt = $conn->prepare("SELECT foto FROM anggota WHERE id=?");
$stmt->bind_param("i", $id);
$stmt->execute();
$r = $stmt->get_result()->fetch_assoc();

if ($r && $r['foto'] && file_exists($r['foto'])) {
  unlink($r['foto']); // hapus file fisik
}

$stmt2 = $conn->prepare("DELETE FROM anggota WHERE id=?");
$stmt2->bind_param("i", $id);
$stmt2->execute();

header("Location: dashboard.php");
exit;
?>

Penjelasan Detail Per Baris hapus.php

BarisKodeArtinya
1session_start(); if (!isset($_SESSION['admin']))Cuma admin boleh hapus.
2SELECT foto FROM anggota WHERE id=?Ambil path foto dulu sebelum baris dihapus, supaya file-nya bisa dihapus juga.
3file_exists($r['foto']) && unlink(...)Kalau file ada di uploads/, hapus. unlink = hapus file fisik, bukan cuma di database.
4DELETE FROM anggota WHERE id=?Hapus baris di database.
5header("Location: dashboard.php")Balik ke dashboard.

7.6 Logout (logout.php) — Full Code

<?php
// logout.php
session_start();
session_unset();   // kosongkan semua $_SESSION
session_destroy(); // hancurkan sesi di server
header("Location: login.php");
exit;
?>

Penjelasan Detail Per Baris logout.php

BarisKodeArtinya
1session_start();Buka sesi dulu baru bisa dihancurkan.
2session_unset();Kosongkan semua data di $_SESSION (nama, id, dll).
3session_destroy();Hancurkan file sesi di server. Gelang login resmi dilepas.
4header("Location: login.php")Tendang balik ke halaman login.

8. Deploy dari Localhost Sampai Online di Hosting

Vercel/GitHub Pages tidak bisa untuk PHP. Kalian butuh hosting dengan PHP + MySQL.

Opsi gratis untuk belajar: InfinityFree (infinityfree.net) — support PHP 8.3 + MySQL + SSL gratis. Atau berbayar murah: Hostinger, Niagahoster, RumahWeb.

Langkah publish:

  1. Export Database Lokal: di HeidiSQL/phpMyAdmin Laragon → pilih organisasi_osis → Export → SQL.
  2. Daftar Hosting: buat akun InfinityFree → Create Account → catat FTP Host, Username, Password, dan buka MySQL Databases buat database baru (misal if0_123_osis).
  3. Upload File: buka File Manager di hosting (atau pakai FileZilla FTP) → masuk folder htdocs → upload semua file osis-app (zip lalu extract biar cepat). Pastikan uploads/ permission 755.
  4. Import Database Hosting: di hosting buka phpMyAdmin → pilih database baru → Import → pilih file .sql tadi.
  5. Ubah config.php di hosting:
$host = "sqlxxx.infinityfree.com"; // lihat di dashboard hosting
$user = "if0_12345678";
$pass = "password_hosting";
$db  = "if0_12345678_osis";
  1. Aktifkan SSL: di hosting menu SSL → Request Free SSL → aktifkan Force HTTPS.
  2. Cek https://osis-sekolahmu.infinityfreeapp.com di HP pakai data seluler.

Kalau mau custom domain osis-sekolahku.my.id, tinggal arahkan nameserver hosting di tempat beli domain.

9. Error Paling Sering dan Solusinya

Pesan ErrorPenyebabSolusi 1 Menit
Access denied for user 'root'@'localhost'Akses ditolak. Password database di config.php tidak cocok dengan MySQL.Cek Laragon: MySQL user root password kosong. Di hosting, ganti dengan user/pass dari dashboard hosting.
Headers already sentKalian echo dulu baru header(). Ibarat sudah kirim surat, baru mau ganti alamat tujuan — tidak bisa.Taruh session_start() paling atas, tanpa spasi/echo sebelumnya.
File too large / foto tidak teruploadFoto 5 MB tapi batas upload server cuma 2 MB.Di Laragon: Menu → PHP → php.ini → ubah upload_max_filesize=8M dan post_max_size=8M, restart Laragon.
Undefined array key "hobi"Tidak pilih checkbox satupun, tapi PHP maksa baca $_POST['hobi']. Ibarat buka keranjang yang tidak ada.Pakai isset($_POST['hobi']) ? ... : []
Duplicate entry 'OSIS-2026-0001'Nomor anggota kembar karena 2 orang di-approve barengan.Kolom nomor_anggota sudah UNIQUE, cek logic COUNT+1 di approve.php — lebih aman pakai MAX(id) atau transaksi.
500 Internal Server Error di hostingconfig.php masih pakai localhost.Ganti host/user/pass DB ke yang dari dashboard hosting, bukan localhost lagi.
Foto tidak tampil di hostingFolder uploads/ tidak ada di hosting.Buat manual folder uploads/ di htdocs dan chmod 755. Foto lama di laptop tidak ikut ke-upload, harus upload ulang.

10. Best Practice Industri

  • Selalu pakai .env untuk kredensial kalau sudah naik level (jangan hardcode password di config.php yang ke-push GitHub). Untuk pemula, cukup config.php tapi masukkan ke .gitignore.
  • Validasi 2 lapis: HTML required + PHP filter_var + MySQL ENUM. Jangan percaya input user. Ibarat periksa KTP 3 kali: di gerbang, di resepsionis, di ruang arsip.
  • Password wajib hash: password_hash("admin123", PASSWORD_DEFAULT) saat buat admin, bukan plain text.
  • Gunakan JSON untuk array, bukan VARCHAR koma. Lebih aman untuk data seperti "Futsal, Coding" yang ada komanya.
  • Beri LIMIT di dashboard kalau anggota sudah 1000+: SELECT ... LIMIT 50 + pagination (materi N8 & cara-membuat-pagination-php-mysql-anti-lemot-1-juta-data).
  • Backup rutin: export .sql tiap minggu, simpan di Google Drive.

11. Kesimpulan

kita baru saja membangun website dari nol sampai online beneran: dari memahami 24 tipe input HTML dan tipe datanya di MySQL, merakit form OSIS yang menampung string, array checkbox, dropdown, date sampai file, menyimpan dengan prepared statement, sampai bikin login admin yang bisa approve, edit, delete, dan generate nomor anggota otomatis.

alur ini persis yang dipakai di dunia industri untuk PPDB, absensi, hingga kasir: form → database → admin panel → hosting. kalau kalian sudah paham OSIS, ganti label jadi Keanggotaan Karang Taruna atau Koperasi Masyarakat pun tinggal ubah SQL dan label formnya saja.

Pertanyaan yang Sering Diajukan

Apa saja tipe input HTML yang wajib ada di form OSIS ini?

Semua: text, email, tel, url, number, password, textarea, radio, checkbox array, select dropdown, select multiple, datalist, date, time, datetime-local, month, week, color, range, file, hidden, dan search. Masing-masing sudah dipetakan ke tipe data PHP dan kolom MySQL (VARCHAR, TEXT, INT, DATE, JSON) di artikel ini.

Bagaimana cara simpan checkbox dan select multiple yang datanya array?

Gunakan atribut name="hobi[]" dan name="ekskul[]" agar PHP menangkapnya sebagai array $_POST. Lalu simpan ke MySQL sebagai JSON dengan json_encode() ke kolom JSON/TEXT, dan saat tampil kembalikan dengan json_decode(). Jangan simpan dengan implode koma jika butuh query spesifik.

Bagaimana nomor anggota OSIS dibuat otomatis?

Nomor anggota tidak diinput manual. Saat admin klik Approve, sistem generate format OSIS-2026-0001 dengan mengambil id terakhir + padding 4 digit. Nomor disimpan di kolom nomor_anggota VARCHAR(20) UNIQUE agar tidak duplikat.

Kenapa website PHP tidak bisa deploy ke Vercel atau GitHub Pages?

Vercel dan GitHub Pages hanya untuk website statis (HTML/CSS/JS). Website PHP + MySQL butuh server dengan Apache/Nginx + PHP + MySQL seperti InfinityFree, Hostinger, Niagahoster, atau VPS. Di artikel ini langkah upload via File Manager/FTP dan import database dijelaskan lengkap.

Apakah admin bisa edit, hapus, dan approve anggota?

Bisa. Setelah login dengan session + password_hash, admin masuk dashboard untuk melihat semua pendaftar status pending, lalu bisa Edit (ubah data), Delete (hapus permanen), dan Approve (ubah status jadi approved + kunci nomor anggota).

Apa beda domain dan hosting untuk website PHP ini?

Hosting adalah server tempat file PHP dan database MySQL disimpan agar online 24 jam. Domain adalah alamat nama (misal osis-sekolahku.my.id) yang mengarah ke hosting tersebut. Untuk PHP, kalian butuh keduanya + SSL gratis agar bisa diakses via HTTPS.

Sigit Nurhanafi avatar
Full-stack developer & technical writer. Berpengalaman di PHP, Laravel, NodeJS, MySQL, dan Python. Aktif menulis tutorial pemrograman dan maintaining open-source projects di PemburuKode sejak 2021.