Wat ga je precies bouwen?
Een echte administratie-app met twee gekoppelde onderdelen: studenten en een landenregister. Dit is bewust groter dan een simpele takenlijst, zodat je patronen leert die je opnieuw kunt gebruiken.
Je hoeft code nog niet te kunnen lezen.
Je werkt eerst met een compleet werkend project. Daarna verander je steeds één klein onderdeel. Boven ieder codeblok staat direct wat het doet, welk bestand je opent en waar je het plakt.
- 1Kies één routeVoor je eerste project: PHP + SQLite.
- 2Download de starterBegin niet met een lege map.
- 3Start hem zonder wijzigingenControleer eerst dat het voorbeeld werkt.
- 4Verander één dingTest opnieuw vóór de volgende wijziging.
Belangrijk: je hoeft nooit een groot codeblok over te typen. Gebruik Kopieer, plak het in het genoemde bestand en verander alleen wat de stap aanwijst.
Woorden die je straks tegenkomtZes begrippen in normaal Nederlands
index.php.Kies de route die bij jou past.
Alle drie bouwen dezelfde applicatie. PHP + SQLite is het eenvoudigst. PHP + MySQL gebruikt de database die al in XAMPP zit. JavaScript + SQLite leert je een frontend, API en backend apart koppelen.
Klik op wat het beste bij je past. Je krijgt meteen een aanbevolen bouwroute.
Jouw bouwroute
Hoe ziet de JavaScript-route eruit?
Het voorbeeld “Herkansing KT2” gebruikt deze route: browser-JavaScript verstuurt een fetch() naar Express, Express voert SQL uit met better-sqlite3 en stuurt JSON terug. Je installeert de basis zo:
npm install express better-sqlite3Deze route is verderop volledig uitgewerkt. Begin met PHP als je bijna geen code-ervaring hebt; kies JavaScript als je specifiek een API en fetch() wilt leren.
Maak de basis voor jouw eigen onderwerp.
Kies wat je wilt beheren, bijvoorbeeld producten of boeken. De builder maakt de benodigde databasevelden, het formulier en de verwerking. Je hoeft de technische namen nog niet zelf te bedenken.
Kom daarna hier terug om “student” te veranderen in jouw eigen onderwerp. Zo weet je dat een fout door je laatste wijziging komt.
Vul geen los ID-veld in. id, created_at en updated_at worden veilig en automatisch toegevoegd. De naam in de database volgt de formuliertekst totdat je die technische naam zelf wijzigt.
HTML numberPHP intJavaScript numberSQL INTEGER / INTHTML numberPHP floatJavaScript numberSQL REAL / DECIMALHTML checkboxPHP bool → 0/1JavaScript boolean → 0/1SQL INTEGER / TINYINTvar is geen PHP-datatype. PHP gebruikt onder andere string, int, float en bool. De generator valideert eerst het echte PHP-type en zet het daarna om naar het passende SQL-type. Een datum blijft bewust een streng gecontroleerde tekenreeks in databaseformaat.
Configuratie is geldig.
Complete PHP-app in één bestand
Kopieer tab 1 en volg deze stappen.
Voor PHP maakt Complete app één volledig index.php-bestand met database, CRUD, HTML, CSS en uitlegcomments. De andere tabs splitsen dezelfde onderdelen op zodat je ze later in een groter project kunt plaatsen.
Zet XAMPP één keer goed klaar.
XAMPP levert Apache, PHP én MySQL. Voor de SQLite-route start je alleen Apache. Voor de MySQL-route start je Apache en MySQL. Kies één database per project.
- 01
Start de onderdelen voor jouw route
Open het XAMPP Control Panel. Start altijd Apache. Start bij PHP + MySQL ook MySQL. Beide rijen moeten groen worden.
- 02
Controleer de juiste projectmap
Je projecten staan normaal in
C:\xampp\htdocs\. Maak daar straks de mapstudenten-crud. - 03
Controleer SQLite
Open XAMPP → Apache → Config → PHP (php.ini). Zoek de volgende regels en haal de puntkomma vooraan weg.
extension=pdo_sqlite extension=sqlite3Daarna: sla
php.iniop en herstart Apache. Zonder herstart ziet PHP de wijziging niet. - 04
Doe de 10-seconden test
Maak tijdelijk
test.phpinhtdocsmet onderstaande code. Zie je SQLite werkt? Dan kun je verder.<?php echo extension_loaded('pdo_sqlite') ? 'SQLite werkt' : 'SQLite ontbreekt';
SQLite is één bestand en het makkelijkst. MySQL draait als XAMPP-service en kun je bekijken via http://localhost/phpmyadmin/.
Maak deze projectstructuur exact na.
Bestandsnamen zijn geen decoratie: PHP zoekt straks op deze paden. Kopieer de structuur dus eerst letterlijk. Daarna vullen we ieder bestand.
studenten-crud/
├── index.php
├── assets/
│ ├── app.css
│ └── app.js
├── config/
│ └── database.php
├── database/
│ └── schema.sql
├── includes/
│ └── functions.php
└── data/
└── (app.sqlite komt automatisch)
index.phpVerwerkt acties en bouwt de HTML-pagina.
database.phpMaakt één veilige PDO-verbinding met SQLite.
schema.sqlBeschrijft tabellen, velden, relaties en startdata.
functions.phpBevat herbruikbare validatie- en beveiligingsfuncties.
Waar hoort code eigenlijk?
Klik op een bestand. Je ziet welke taak het heeft, welke delen erin staan en wat je daar wel of niet mag plakken.
De hoofdpagina en alle acties
“Plakken” betekent niet altijd hetzelfde.
Open het bestand, druk Ctrl A, plak de complete bestandscode en sla op. Gebruik dit als je niet weet hoe PHP in elkaar zit.
De gids geeft een beginregel en eindregel. Selecteer inclusief beide regels en plak het nieuwe blok één keer.
Sommige functies hebben database-, PHP- én formuliercode nodig. Voer dan ieder genummerd deel uit; één los blok is niet genoeg.
Nooit doen: code ergens onderaan plakken omdat daar ruimte is. PHP-code vóór de HTML en HTML-code ín de pagina zijn verschillende plekken.
De hele map staat al voor je klaar.
Pak het ZIP-bestand uit in htdocs en open daarna http://localhost/studenten-crud/.
Koppel wat je ziet aan wat je app doet.
Je frontend bestaat niet uit mysterieuze losse code. HTML zet onderdelen op de pagina, CSS maakt ze mooi en PHP of JavaScript verwerkt wat de gebruiker doet. Hieronder verbind je de echte bestanden uit jouw starter zonder zelf paden te hoeven raden.
Formulieren, knoppen, koppen en tabellen.
<input>Kleuren, ruimte, lettertypes en mobiele layout.
.stack-formPHP of JavaScript leest de invoer en kiest een CRUD-actie.
createSQLite of MySQL bewaart de gegevens in een tabel.
INSERTLaat alleen jouw koppelstappen zien.
Deze bestanden staan niet los van elkaar.
index.php staat in de hoofdmap. Daarom begint het pad naar CSS en JavaScript met assets/.
- 1studenten-crud/index.phpBevat de HTML én PHP-functionaliteit.
- 2studenten-crud/assets/app.cssWordt vanuit index.php geladen voor de vormgeving.
- 3studenten-crud/assets/app.jsRegelt alleen menu en verwijderbevestiging; PHP doet de CRUD.
<link rel="stylesheet" href="assets/app.css">
<script src="assets/app.js" defer></script>
Waarom geen C:\xampp\... in deze regels? De browser rekent vanaf de map van index.php. assets/app.css betekent dus: ga vanuit studenten-crud naar de map assets en open app.css.
Voeg één zichtbaar blok toe en geef het stijl.
Doe stap 1 in index.php en stap 2 in assets/app.css. Dezelfde classnaam begin-kaart verbindt de twee bestanden.
Open index.php. Zoek <main class="app-shell"> en plak dit direct op de volgende regel.
<section class="begin-kaart">
<p>Mijn eerste eigen onderdeel</p>
<h2>Welkom bij mijn administratie</h2>
</section>Open assets/app.css, ga helemaal naar beneden en plak dit na de laatste regel.
.begin-kaart {
margin-bottom: 24px;
padding: 24px;
border-left: 4px solid #1e56dc;
background: #ffffff;
}
.begin-kaart p {
margin: 0 0 6px;
color: #1e56dc;
font-weight: 700;
}http://localhost/studenten-crud/ → druk Ctrl F5. Zie je het witte blok met een blauwe lijn? Dan zijn HTML en CSS goed gekoppeld.Eén veld moet overal dezelfde codenaam houden.
Klik op een voorbeeld. De blauwe woorden moeten bij elkaar blijven horen wanneer je jouw thema verandert.
De zichtbare tekst mag anders zijn. Je mag Voornaam veranderen in Roepnaam, maar verander voornaam alleen als je dit in alle vier de blokken tegelijk doet.
Complete PHP-frontendbestanden
Wil je niet zoeken of losse delen plaatsen? Kies hieronder een bestand, klik op Kopieer bestand en vervang exact dat hele bestand met Ctrl A → Ctrl V.
Bestand wordt geladen…Deze drie bestanden staan naast elkaar.
Omdat index.html, style.css en app.js in dezelfde map staan, gebruikt de HTML alleen ./style.css en ./app.js.
- 1frontend/index.htmlFormulieren, knoppen, tabellen en lege plekken voor data.
- 2frontend/style.cssAlle vormgeving van de pagina.
- 3frontend/app.jsLeest de HTML, gebruikt fetch en vult het overzicht.
<link rel="stylesheet" href="./style.css"><script src="./app.js" defer></script>Belangrijk: open frontend/index.html niet door erop te dubbelklikken. Start eerst npm start en open http://localhost:3000. Express levert dan zowel de frontend als de API.
Een HTML-id wordt gekoppeld aan een JavaScriptnaam.
Klik op een veld en volg het van de invulbox naar SQLite en terug naar de tabel.
HTML gebruikt vaak een streepje en JavaScript camelCase. Dat is goed zolang de koppeling letterlijk klopt: #first-name wordt één keer gekoppeld aan elements.firstName.
Complete JavaScript-frontendbestanden
Deze drie bestanden werken als set. Vervang een bestand altijd op het pad dat links op de knop staat.
Bestand wordt geladen…Gebruik class="primary-button" voor CSS die meerdere knoppen mag stylen. Gebruik id="student-form" wanneer JavaScript precies één onderdeel moet vinden. Verander een id niet zonder ook de JavaScript-selector te veranderen.
Koppel PHP aan SQLite.
Er zijn twee delen: schema.sql beschrijft wat er wordt opgeslagen. database.php opent het databasebestand en voert het schema uit.
PDO is de standaard PHP-laag waarmee je databasevragen uitvoert. Een prepared statement houdt gebruikersinvoer apart van SQL en voorkomt SQL-injectie.
$connection = new PDO('sqlite:' . $databaseFile, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$connection->exec('PRAGMA foreign_keys = ON');
$connection->exec(file_get_contents($schemaFile));
Regel voor regel
new PDO('sqlite:' ...)- Opent SQLite op het opgegeven bestandspad.
ERRMODE_EXCEPTION- Zorgt dat een SQL-fout een duidelijke foutmelding wordt.
foreign_keys = ON- Dwingt de relatie tussen student en land echt af.
schema.sql- Maakt tabellen alleen als ze nog niet bestaan.
Liever MySQL uit XAMPP?
Dat kan. De formulieren en bijna alle PHP-code blijven hetzelfde. Alleen de databaseverbinding en enkele regels in het schema veranderen. Start bij deze route Apache én MySQL.
data/app.sqlite- 01
Start Apache en MySQL
Open XAMPP en zorg dat beide rijen groen zijn. Open daarna
http://localhost/phpmyadmin/om te controleren dat MySQL reageert. - 02
Gebruik de MySQL-starter
De starter gebruikt lokaal de standaard XAMPP-login: gebruiker
rootmet een leeg wachtwoord. Heb jij een wachtwoord ingesteld, wijzig dat bovenaanconfig/database.php. - 03
Laat PHP de database maken
Bij de eerste paginaweergave maakt PHP automatisch de database
studenten_crud, de tabellen en vijf voorbeeldlanden.
$connection = new PDO(
'mysql:host=127.0.0.1;port=3306;charset=utf8mb4',
'root',
'',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$connection->exec('CREATE DATABASE IF NOT EXISTS studenten_crud');
$connection->exec('USE studenten_crud');
Eerst verbinding, tabel, toevoegen en overzicht.
Loginrollen, repositories en frameworks zijn niet nodig om je eerste CRUD te begrijpen.
Voeg beveiliging en structuur in lagen toe.
De download bevat prepared statements, validatie en CSRF al veilig, met comments erbij.
Bestand wordt geladen…
PHP + MySQL starter
Uitpakken in htdocs, Apache en MySQL starten, pagina openen.
Bouw CRUD in deze volgorde.
Werk niet aan alles tegelijk. Iedere tab hieronder noemt het bestand, de eerste regel en de laatste regel. Daardoor hoef je niet te raden waar de code hoort.
Zo gebruik je de zoekankers.
Je zoekt niet naar een regelnummer, want regelnummers veranderen. Je zoekt naar tekst die letterlijk in jouw starter staat.
case 'create_student':De editor springt naar het begin van Create.break;Neem de eerste break; na het beginanker mee.Ctrl V · Ctrl SJe voegt dus geen tweede Create-blok toe.Create — een student toevoegen
Het formulier verstuurt een POST-request. PHP valideert de velden en voert daarna één INSERT uit.
case 'create_student':
$student = student_input($db);
$statement = $db->prepare(
'INSERT INTO studenten
(studentnummer, voornaam, achternaam, email, opleiding, land_id, status, updated_at)
VALUES
(:studentnummer, :voornaam, :achternaam, :email, :opleiding, :land_id, :status, CURRENT_TIMESTAMP)'
);
$statement->execute($student);
flash('success', 'Student is toegevoegd.');
break;Omdat de actie gegevens verandert.
:voornaam is een veilige placeholder.
Na opslaan redirect PHP naar het overzicht.
Read — rijen ophalen en tonen
Een SELECT haalt de studenten op. De foreach-lus maakt voor iedere rij een tabelregel.
$studentStatement = $db->prepare(
'SELECT
studenten.*,
landen.naam AS land_naam,
landen.code AS land_code
FROM studenten
JOIN landen ON landen.id = studenten.land_id
' . $whereSql . '
ORDER BY ' . $sortOptions[$sort] . '
LIMIT ' . $perPage . ' OFFSET ' . $offset
);
$studentStatement->execute($studentParams);
$students = $studentStatement->fetchAll();De student bewaart alleen land_id; JOIN haalt de landnaam erbij.
e()?Het ontsmet tekst voordat die in HTML komt.
Daarmee bepaal jij de volgorde van het overzicht.
Update — één rij wijzigen
Laad eerst de bestaande student in het formulier. Bij opslaan bepaalt de verborgen id welke rij verandert.
case 'update_student':
$id = positive_id($_POST['id'] ?? null, 'Student-ID');
$student = student_input($db);
$student['id'] = $id;
$statement = $db->prepare(
'UPDATE studenten SET
studentnummer = :studentnummer,
voornaam = :voornaam,
achternaam = :achternaam,
email = :email,
opleiding = :opleiding,
land_id = :land_id,
status = :status,
updated_at = CURRENT_TIMESTAMP
WHERE id = :id'
);
$statement->execute($student);
if ($statement->rowCount() === 0) {
$exists = $db->prepare('SELECT COUNT(*) FROM studenten WHERE id = ?');
$exists->execute([$id]);
if ((int) $exists->fetchColumn() === 0) {
throw new RuntimeException('Student niet gevonden.');
}
}
flash('success', 'Student is bijgewerkt.');
break;Zonder WHERE id = :id wijzig je iedere student.
Het formulier weet zo welke rij je aan het bewerken bent.
Ja. Create en Update gebruiken allebei student_input().
Delete — veilig verwijderen
Verwijderen gebruikt ook POST, een CSRF-token en een exact ID. Gebruik hiervoor nooit een simpele GET-link.
case 'delete_student':
$id = positive_id($_POST['id'] ?? null, 'Student-ID');
$statement = $db->prepare('DELETE FROM studenten WHERE id = ?');
$statement->execute([$id]);
if ($statement->rowCount() === 0) {
throw new RuntimeException('Student niet gevonden.');
}
flash('success', 'Student is verwijderd.');
break;Voorkomt dat een andere site namens jou een verwijderactie stuurt.
??Dat is een positionele placeholder voor het ID.
Je ontdekt daarmee of er echt een rij is verwijderd.
Testcheck na ieder onderdeel
- Voeg één student toe.
- Vernieuw de pagina: blijft de student staan?
- Wijzig de achternaam.
- Verwijder de teststudent.
Complete PHP-bestanden, zonder gaten.
Dit is de veiligste manier als je PHP nog niet begrijpt: vervang één heel bestand. Je hoeft dan niet te bepalen waar een functie begint of eindigt.
Een compleet bestand vervangen in vijf klikken.
- 1
Klik links op het bestand dat je wilt vervangen.
- 2
Klik rechtsboven op Kopieer bestand.
- 3
Open in VS Code exact hetzelfde pad in jouw project.
- 4
Druk Ctrl A en daarna Ctrl V.
- 5
Sla op met Ctrl S en vernieuw localhost.
Voorbeeld: kies je links config/database.php, dan open je in VS Code de map config en daarna database.php. Je plakt dus niet in index.php.
Bestand wordt geladen…
iBegin je vanaf nul? Download dan de ZIP. De Codebank is vooral handig wanneer je één beschadigd of aangepast bestand wilt vervangen.
Bouw dezelfde CRUD met JavaScript.
Deze route gebruikt JavaScript op twee plekken. Browser-JavaScript leest formulieren en toont rijen. Node.js + Express ontvangt API-requests en voert SQL uit met better-sqlite3.
Bij PHP verstuurt een formulier rechtstreeks naar index.php en bouwt PHP opnieuw de HTML. Bij deze JavaScript-route blijft de pagina open, haalt fetch() JSON op en rendert app.js het overzicht opnieuw.
- 01
Installeer Node.js en de packages
Open een terminal in de startermap.
npm installleestpackage.jsonen installeert Express en better-sqlite3.npm install - 02
Start de backend
npm startopent de SQLite-database, maakt tabellen, registreert API-routes en serveert de frontend op poort 3000.npm start - 03
Open de applicatie
Ga naar
http://localhost:3000. Gebruik niet Live Server: de Express-backend moet de pagina en API samen serveren.
javascript-sqlite-crud/
├── package.json
├── backend/
│ └── server.js
├── frontend/
│ ├── index.html
│ ├── style.css
│ └── app.js
└── data/
└── app.sqlite
server.jsExpress, SQLite, validatie en alle API-routes.
index.htmlFormulieren, filters, tabellen en dashboard.
app.jsfetch, state, renderfuncties en events.
app.sqliteWordt automatisch bij de eerste start gemaakt.
1. Database en tabel maken
De backend opent eerst het bestand. Daarna voert db.exec() de tabeldefinities uit. IF NOT EXISTS maakt opnieuw starten veilig.
const Database = require('better-sqlite3');
const db = new Database('data/app.sqlite');
db.pragma('foreign_keys = ON');
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS students (
id INTEGER PRIMARY KEY AUTOINCREMENT,
student_number TEXT NOT NULL UNIQUE,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
)
`);2. API-route maken
De URL en HTTP-methode vertellen welke actie wordt uitgevoerd. De backend valideert req.body, voert een prepared INSERT uit en stuurt JSON terug.
app.post('/api/students', (req, res) => {
const student = readStudent(req.body);
const result = db.prepare(`
INSERT INTO students
(student_number, first_name, last_name, email)
VALUES
(@studentNumber, @firstName, @lastName, @email)
`).run(student);
res.status(201).json(getStudent(result.lastInsertRowid));
});3. Frontend koppelen met fetch
De browser zet het formulier om naar JSON. Na een succesvol antwoord wordt het overzicht opnieuw geladen. Daardoor verschijnt de nieuwe student zonder volledige paginavernieuwing.
async function createStudent(student) {
const response = await fetch('/api/students', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(student)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error);
}
return response.json();
}GET/api/studentsSELECTPOST/api/studentsINSERTPUT/api/students/:idUPDATEDELETE/api/students/:idDELETEVolledige JavaScript-codebank
Ieder tabblad hieronder is één compleet bestand. De naam op de knop is ook het exacte pad waar je het bestand moet vervangen.
Kies bestand → kopieer → open hetzelfde pad → Ctrl A → Ctrl V.
Voorbeeld: backend/server.js hoort in de map backend. frontend/app.js hoort in de map frontend. Meng deze twee bestanden nooit.
Bestand wordt geladen…
JavaScript + SQLite starter
Na uitpakken: npm install, npm start, open poort 3000.
De API bestaat alleen terwijl node backend/server.js draait. Gebruik altijd http://localhost:3000.
Voeg functies toe zonder opnieuw te beginnen.
Deze recepten zijn aanvullingen op de starters. Ieder recept bevat nu niet alleen de functionaliteit, maar ook het HTML-deel dat je gebruiker ziet en de CSS die daarbij hoort. Voer de delen altijd in de aangegeven volgorde uit.
Zeg wat je app moet kunnen.
Je hoeft de technische naam niet te kennen. Kies een gewone taak; de juiste uitleg wordt voor je geopend.
Kies hierboven één functie. Voeg extra functies pas toe wanneer Create, Read, Update en Delete al werken.
Voeg niet alles tegelijk toe.
Maak na iedere rij hieronder één testaccount of testrecord. Ga pas verder als die stap werkt.
- 1. BasisGenerator: Create, Read, Update en Delete
- 2. GebruikersLogin → gebruikersbeheer → rollen → profiel
- 3. OverzichtZoeken → filteren → paginering → bulkacties
- 4. ControlePrullenbak → activiteitenlog → CSV export
Ik wil zelf verschillende soorten velden kiezenOpen het technische overzicht alleen wanneer je eigen velden gaat toevoegen.
textTEXTVARCHAR(160)verplicht + maximale lengtetextareaTEXTTEXTspaties verwijderen + maximale lengtenumberINTEGERINTcontroleer of het een heel getal isnumber step=.01REALDECIMAL(10,2)mag niet lager dan 0 zijndateTEXTDATEformaat jaar-maand-dagcheckboxINTEGERTINYINT(1)bewaren als nee (0) of ja (1)01Studenten aan een land koppelenTechnische naam: one-to-many relatie.Database
Waar plakken: in database/schema.sql. Maak altijd eerst de bovenliggende tabel landen.
CREATE TABLE studenten (
id INTEGER PRIMARY KEY AUTOINCREMENT,
naam TEXT NOT NULL,
land_id INTEGER NOT NULL,
FOREIGN KEY (land_id) REFERENCES landen(id) ON DELETE RESTRICT
);
SELECT studenten.*, landen.naam AS land_naam
FROM studenten
JOIN landen ON landen.id = studenten.land_id;land_id is dezelfde naam die de databasekoppeling gebruikt.Plak in: index.php, binnen het studentenformulier op de plek waar het land moet verschijnen.
<label class="relation-select">
<span>Land</span>
<select name="land_id" required>
<option value="">Kies een land</option>
<?php foreach ($countries as $country): ?>
<option value="<?= e($country['id']) ?>">
<?= e($country['naam']) ?>
</option>
<?php endforeach; ?>
</select>
<small>Dit land wordt aan de student gekoppeld.</small>
</label>.relation-select is gelijk aan de HTML-class hierboven.Plak in: assets/app.css, helemaal onderaan.
.relation-select {
display: grid;
gap: 7px;
}
.relation-select > span {
color: #546178;
font-size: 12px;
font-weight: 700;
}
.relation-select select {
width: 100%;
min-height: 44px;
padding: 10px 12px;
border: 1px solid #cbd2df;
background: #ffffff;
}
.relation-select small {
color: #647083;
font-size: 11px;
}ON DELETE RESTRICT voorkomt dat je een land verwijdert dat nog in gebruik is.
U1Complete gebruikersbeheer-CRUDGebruikers toevoegen, bekijken, wijzigen en verwijderen.Users + PHP
Wat je krijgt: een adminscherm met Create, Read, Update en Delete voor gebruikers. Wachtwoorden worden met password_hash() bewaard.
-- SQLITE · GEBRUIKERS: e-mail is uniek en active bepaalt of iemand mag inloggen.
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'medewerker',
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);-- MYSQL · DEZELFDE VELDEN ALS DE SQLITE-VERSIE HIERBOVEN.
CREATE TABLE IF NOT EXISTS users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(160) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(30) NOT NULL DEFAULT 'medewerker',
active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;// GEBRUIKERS-CRUD: verwerk alleen formulieren met een user_-actie.
if ($_SERVER['REQUEST_METHOD'] === 'POST'
&& str_starts_with(post_text('intent'), 'user_')
) {
try {
verify_csrf($_POST['_token'] ?? null);
$intent = post_text('intent');
if ($intent === 'user_create') {
$email = strtolower(required_text('email', 'E-mailadres', 160));
$password = required_text('password', 'Wachtwoord', 200);
$role = in_array(post_text('role'), ['admin', 'medewerker'], true)
? post_text('role') : 'medewerker';
if (strlen($password) < 10) throw new RuntimeException('Gebruik minstens 10 tekens.');
$statement = $db->prepare(
'INSERT INTO users (email, password_hash, role) VALUES (?, ?, ?)'
);
$statement->execute([$email, password_hash($password, PASSWORD_DEFAULT), $role]);
flash('success', 'Gebruiker is toegevoegd.');
}
if ($intent === 'user_update') {
$id = positive_id($_POST['id'] ?? null, 'Gebruiker');
$email = strtolower(required_text('email', 'E-mailadres', 160));
$role = in_array(post_text('role'), ['admin', 'medewerker'], true)
? post_text('role') : 'medewerker';
$active = isset($_POST['active']) ? 1 : 0;
if ($id === (int) ($_SESSION['user_id'] ?? 0) && ($active === 0 || $role !== 'admin')) {
throw new RuntimeException('Je kunt je eigen actieve adminrechten niet uitschakelen.');
}
$db->prepare('UPDATE users SET email = ?, role = ?, active = ? WHERE id = ?')
->execute([$email, $role, $active, $id]);
flash('success', 'Gebruiker is bijgewerkt.');
}
// WACHTWOORD RESETTEN: een admin geeft een tijdelijk wachtwoord op.
if ($intent === 'user_reset_password') {
$id = positive_id($_POST['id'] ?? null, 'Gebruiker');
$password = required_text('password', 'Tijdelijk wachtwoord', 200);
if (strlen($password) < 10) throw new RuntimeException('Gebruik minstens 10 tekens.');
$db->prepare('UPDATE users SET password_hash = ? WHERE id = ?')
->execute([password_hash($password, PASSWORD_DEFAULT), $id]);
flash('success', 'Tijdelijk wachtwoord is ingesteld.');
}
if ($intent === 'user_delete') {
$id = positive_id($_POST['id'] ?? null, 'Gebruiker');
if ($id === (int) ($_SESSION['user_id'] ?? 0)) {
throw new RuntimeException('Je kunt je eigen ingelogde account niet verwijderen.');
}
$db->prepare('DELETE FROM users WHERE id = ?')->execute([$id]);
flash('success', 'Gebruiker is verwijderd.');
}
} catch (Throwable $userError) {
// BEGINNERSVRIENDELIJK: toon de fout in de bestaande flashmelding.
flash('error', $userError->getMessage());
}
redirect('index.php#user-admin');
}
// READ: laad alle gebruikers voor het overzicht.
$users = $db->query(
'SELECT id, email, role, active, created_at FROM users ORDER BY id DESC'
)->fetchAll();intent- en inputnamen zijn gelijk aan het PHP-blok.Plak in: index.php, vóór de footer en alleen op een pagina die voor admins is beschermd.
<section class="user-admin" id="user-admin">
<header><p>Gebruikersbeheer</p><h2>Accounts</h2></header>
<form class="user-create" method="post">
<input type="hidden" name="_token" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="intent" value="user_create">
<label><span>E-mail</span><input name="email" type="email" required></label>
<label><span>Tijdelijk wachtwoord</span><input name="password" type="password" minlength="10" required></label>
<label><span>Rol</span><select name="role"><option value="medewerker">Medewerker</option><option value="admin">Admin</option></select></label>
<button type="submit">Gebruiker toevoegen</button>
</form>
<div class="user-list">
<?php foreach ($users as $user): ?>
<article class="user-row">
<form method="post">
<input type="hidden" name="_token" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="intent" value="user_update">
<input type="hidden" name="id" value="<?= e($user['id']) ?>">
<input name="email" type="email" value="<?= e($user['email']) ?>" required>
<select name="role"><option value="medewerker" <?= $user['role'] === 'medewerker' ? 'selected' : '' ?>>Medewerker</option><option value="admin" <?= $user['role'] === 'admin' ? 'selected' : '' ?>>Admin</option></select>
<label class="user-active"><input name="active" type="checkbox" value="1" <?= $user['active'] ? 'checked' : '' ?>> Actief</label>
<button type="submit">Opslaan</button>
</form>
<form class="user-reset" method="post">
<input type="hidden" name="_token" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="intent" value="user_reset_password">
<input type="hidden" name="id" value="<?= e($user['id']) ?>">
<input name="password" type="password" minlength="10" placeholder="Nieuw tijdelijk wachtwoord" required>
<button type="submit">Wachtwoord resetten</button>
</form>
<form method="post" onsubmit="return confirm('Gebruiker verwijderen?')">
<input type="hidden" name="_token" value="<?= e(csrf_token()) ?>"><input type="hidden" name="intent" value="user_delete"><input type="hidden" name="id" value="<?= e($user['id']) ?>">
<button class="user-delete" type="submit">Verwijder</button>
</form>
</article>
<?php endforeach; ?>
</div>
</section>user-admin staat in de HTML.Plak in: assets/app.css, onderaan.
/* GEBRUIKERSBEHEER: layout voor Create, Read, Update en Delete. */
.user-admin { margin-top: 48px; padding: 24px; border: 1px solid #d9dee8; background: #fff; }
.user-admin header p { margin: 0; color: #1e56dc; font-weight: 700; text-transform: uppercase; }
.user-admin header h2 { margin: 4px 0 20px; }
.user-create { display: grid; grid-template-columns: 1fr 1fr 180px auto; gap: 10px; padding: 16px; background: #f5f7fa; }
.user-create label { display: grid; gap: 5px; font-size: 12px; font-weight: 700; }
.user-create input, .user-create select, .user-row input, .user-row select { min-height: 42px; padding: 8px 10px; border: 1px solid #cbd2df; }
.user-create button, .user-row button { min-height: 42px; padding: 0 12px; border: 0; background: #1e56dc; color: #fff; font-weight: 700; }
.user-row { display: grid; grid-template-columns: 1fr minmax(220px, .55fr) auto; gap: 8px; padding: 14px 0; border-bottom: 1px solid #e6e9ee; }
.user-row > form:first-child { display: grid; grid-template-columns: 1fr 150px 90px auto; gap: 8px; }
.user-reset { display: grid; grid-template-columns: 1fr auto; gap: 8px; }
.user-active { display: flex; align-items: center; }
.user-delete { background: #fff0f1 !important; color: #b62935 !important; }
@media (max-width: 850px) { .user-create, .user-row, .user-row > form:first-child, .user-reset { grid-template-columns: 1fr; } }U2Rollen en rechten toevoegenAdmins beheren alles; medewerkers krijgen alleen toegestane acties.Users + PHP
Voorwaarde: login bewaart $_SESSION['role']. Controleer rechten altijd in PHP; alleen een knop verbergen is geen beveiliging.
// RECHTEN: één centrale lijst voorkomt losse, verschillende controles.
function can(string $permission): bool
{
$role = (string) ($_SESSION['role'] ?? 'gast');
$permissions = [
'admin' => ['view', 'create', 'update', 'delete', 'manage_users'],
'medewerker' => ['view', 'create', 'update'],
'gast' => [],
];
return in_array($permission, $permissions[$role] ?? [], true);
}
function require_permission(string $permission): void
{
if (!can($permission)) {
http_response_code(403);
exit('Je hebt geen toestemming voor deze actie.');
}
}
// Voorbeeld bovenaan gebruikersbeheer:
// require_permission('manage_users');
// Doe óók require_permission('delete') in de delete-actie.Plak in: de navigatie van index.php.
<nav class="role-access" aria-label="Beheeronderdelen">
<a href="index.php">Overzicht</a>
<?php if (can('create')): ?><a href="#student-form">Toevoegen</a><?php endif; ?>
<?php if (can('manage_users')): ?><a href="#user-admin">Gebruikers</a><?php endif; ?>
<span class="role-access__badge"><?= e($_SESSION['role'] ?? 'gast') ?></span>
</nav>role-access koppelt CSS en HTML.Plak in: assets/app.css, onderaan.
/* RECHTENMENU: zichtbare navigatie voor de huidige rol. */
.role-access { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; padding: 12px; background: #10233f; }
.role-access a { padding: 8px 11px; color: #fff; font-weight: 700; text-decoration: none; }
.role-access a:hover { background: #1e56dc; }
.role-access__badge { margin-left: auto; padding: 5px 9px; background: #dff2e8; color: #12663e; font-size: 11px; font-weight: 700; text-transform: uppercase; }U3Eigen profiel en wachtwoord wijzigenEen ingelogde gebruiker beheert veilig het eigen account.Users + PHP
Beveiliging: voor een nieuw wachtwoord moet het huidige wachtwoord eerst kloppen. Het gebruikers-id komt uit de sessie, nooit uit een verborgen formulierinput.
// PROFIEL: gebruik altijd het id uit de ingelogde sessie.
$userId = positive_id($_SESSION['user_id'] ?? null, 'Gebruiker');
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf($_POST['_token'] ?? null);
$intent = post_text('intent');
if ($intent === 'profile_update') {
$email = strtolower(required_text('email', 'E-mailadres', 160));
$db->prepare('UPDATE users SET email = ? WHERE id = ?')
->execute([$email, $userId]);
}
if ($intent === 'profile_password') {
$current = (string) ($_POST['current_password'] ?? '');
$new = (string) ($_POST['new_password'] ?? '');
$hash = $db->prepare('SELECT password_hash FROM users WHERE id = ?');
$hash->execute([$userId]);
if (!password_verify($current, (string) $hash->fetchColumn())) {
throw new RuntimeException('Het huidige wachtwoord klopt niet.');
}
if (strlen($new) < 10) throw new RuntimeException('Gebruik minstens 10 tekens.');
$db->prepare('UPDATE users SET password_hash = ? WHERE id = ?')
->execute([password_hash($new, PASSWORD_DEFAULT), $userId]);
}
redirect('profile.php');
}
$statement = $db->prepare('SELECT email, role FROM users WHERE id = ?');
$statement->execute([$userId]);
$profile = $statement->fetch();Plak in: profile.php, onder het PHP-deel.
<main class="profile-settings">
<header><p>Mijn account</p><h1>Profielinstellingen</h1></header>
<form method="post">
<input type="hidden" name="_token" value="<?= e(csrf_token()) ?>"><input type="hidden" name="intent" value="profile_update">
<h2>E-mailadres</h2>
<label><span>E-mail</span><input name="email" type="email" value="<?= e($profile['email']) ?>" required></label>
<button type="submit">Profiel opslaan</button>
</form>
<form method="post">
<input type="hidden" name="_token" value="<?= e(csrf_token()) ?>"><input type="hidden" name="intent" value="profile_password">
<h2>Wachtwoord veranderen</h2>
<label><span>Huidig wachtwoord</span><input name="current_password" type="password" required></label>
<label><span>Nieuw wachtwoord</span><input name="new_password" type="password" minlength="10" required></label>
<button type="submit">Wachtwoord wijzigen</button>
</form>
</main>profile-settings.Plak in: assets/app.css, onderaan.
/* PROFIEL: twee losse kaarten voorkomen dat acties door elkaar lopen. */
.profile-settings { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; width: min(920px, 92vw); margin: 50px auto; }
.profile-settings > header { grid-column: 1 / -1; }
.profile-settings header p { margin: 0; color: #1e56dc; font-weight: 700; text-transform: uppercase; }
.profile-settings form { display: grid; gap: 14px; padding: 24px; border: 1px solid #d9dee8; background: #fff; }
.profile-settings label { display: grid; gap: 6px; font-weight: 700; }
.profile-settings input { min-height: 44px; padding: 10px; border: 1px solid #cbd2df; }
.profile-settings button { min-height: 44px; border: 0; background: #1e56dc; color: #fff; font-weight: 700; }
@media (max-width: 700px) { .profile-settings { grid-template-columns: 1fr; } .profile-settings > header { grid-column: auto; } }02Studenten meerdere vakken gevenTechnische naam: many-to-many relatie.Database
Waar plakken: onderaan database/schema.sql. Dit maakt zowel de vakkenlijst als de koppeltabel. Daarna zorgt het afzonderlijke PHP-blok voor opslaan en opnieuw laden.
CREATE TABLE IF NOT EXISTS vakken (
id INTEGER PRIMARY KEY AUTOINCREMENT,
naam TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS student_vakken (
student_id INTEGER NOT NULL,
vak_id INTEGER NOT NULL,
PRIMARY KEY (student_id, vak_id),
FOREIGN KEY (student_id) REFERENCES studenten(id) ON DELETE CASCADE,
FOREIGN KEY (vak_id) REFERENCES vakken(id) ON DELETE CASCADE
);
INSERT OR IGNORE INTO vakken (naam) VALUES
('HTML en CSS'),
('PHP'),
('Databases');$vakken = $db->query('SELECT id, naam FROM vakken ORDER BY naam')->fetchAll();
if ($_SERVER['REQUEST_METHOD'] === 'POST'
&& post_text('intent') === 'assign_subjects'
) {
verify_csrf($_POST['_token'] ?? null);
$studentId = positive_id($_POST['student_id'] ?? null, 'Student');
$vakIds = array_values(array_unique(array_map(
'intval',
$_POST['vak_ids'] ?? []
)));
if ($vakIds === [] || in_array(0, $vakIds, true)) {
throw new RuntimeException('Kies minstens één geldig vak.');
}
$db->beginTransaction();
try {
$db->prepare('DELETE FROM student_vakken WHERE student_id = ?')
->execute([$studentId]);
$insertVak = $db->prepare(
'INSERT INTO student_vakken (student_id, vak_id) VALUES (?, ?)'
);
foreach ($vakIds as $vakId) {
$insertVak->execute([$studentId, $vakId]);
}
$db->commit();
redirect('index.php');
} catch (Throwable $error) {
$db->rollBack();
throw $error;
}
}vak_ids[] stuurt alle gekozen ids als één lijst naar PHP.Plak in: index.php, in het zichtbare HTML-deel vóór de footer.
<form class="subject-picker" method="post">
<input type="hidden" name="_token" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="intent" value="assign_subjects">
<h3>Vakken aan een student koppelen</h3>
<label class="subject-picker__student">
<span>Student</span>
<select name="student_id" required>
<?php foreach ($students as $student): ?>
<option value="<?= e($student['id']) ?>">
<?= e($student['voornaam'] . ' ' . $student['achternaam']) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<fieldset>
<legend>Kies één of meer vakken</legend>
<div class="subject-picker__options">
<?php foreach ($vakken as $vak): ?>
<label>
<input type="checkbox" name="vak_ids[]" value="<?= e($vak['id']) ?>">
<span><?= e($vak['naam']) ?></span>
</label>
<?php endforeach; ?>
</div>
</fieldset>
<button class="subject-picker__submit" type="submit">Vakken opslaan</button>
</form>subject-picker.Plak in: assets/app.css, helemaal onderaan.
.subject-picker {
margin: 0;
padding: 18px;
border: 1px solid #d9dee8;
}
.subject-picker legend {
padding: 0 7px;
font-weight: 700;
}
.subject-picker__options {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
}
.subject-picker__options label {
display: flex;
gap: 9px;
align-items: center;
padding: 11px;
border: 1px solid #d9dee8;
background: #ffffff;
cursor: pointer;
}
.subject-picker__options input {
width: 18px;
height: 18px;
}03Een pagina voor één student makenBijvoorbeeld: open student 12 op een eigen detailpagina.PHP
Nieuw bestand: detail.php. De functie positive_id() en e() staan al in de PHP-starter.
<?php
session_start();
require __DIR__ . '/config/database.php';
require __DIR__ . '/includes/functions.php';
$db = database();
$id = positive_id($_GET['id'] ?? null, 'Student-ID');
$statement = $db->prepare('SELECT * FROM studenten WHERE id = ?');
$statement->execute([$id]);
$student = $statement->fetch();
if (!$student) {
http_response_code(404);
exit('Student niet gevonden.');
}
?>$student uit het PHP-blok.Plak in: hetzelfde nieuwe bestand detail.php, direct onder de afsluitende ?>.
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Studentdetails</title>
<link rel="stylesheet" href="assets/app.css">
</head>
<body>
<main class="student-detail">
<a class="student-detail__back" href="index.php">← Terug naar overzicht</a>
<article class="student-detail__card">
<p class="student-detail__label">Student</p>
<h1><?= e($student['voornaam'] . ' ' . $student['achternaam']) ?></h1>
<dl>
<div><dt>E-mail</dt><dd><?= e($student['email']) ?></dd></div>
<div><dt>Opleiding</dt><dd><?= e($student['opleiding']) ?></dd></div>
<div><dt>Status</dt><dd><?= e($student['status']) ?></dd></div>
</dl>
</article>
</main>
</body>
</html>detail.php laadt hetzelfde assets/app.css-bestand als de hoofdapp.Plak in: assets/app.css, helemaal onderaan.
.student-detail {
width: min(720px, 92vw);
margin: 0 auto;
padding: 70px 0;
}
.student-detail__back {
display: inline-block;
margin-bottom: 18px;
color: #1e56dc;
font-weight: 700;
text-decoration: none;
}
.student-detail__card {
padding: clamp(24px, 5vw, 48px);
border: 1px solid #d9dee8;
background: #ffffff;
box-shadow: 0 18px 48px rgba(16, 35, 63, 0.08);
}
.student-detail__label {
color: #1e56dc;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.student-detail h1 { margin: 0 0 28px; }
.student-detail dl { margin: 0; }
.student-detail dl div { padding: 14px 0; border-top: 1px solid #d9dee8; }
.student-detail dt { color: #647083; font-size: 12px; }
.student-detail dd { margin: 3px 0 0; font-weight: 700; }04Één student ophalen met JavaScriptDe browser vraagt één student aan de API.JavaScript
Gebruik je de downloadstarter? Dan bestaat GET /api/students/:id al en sla je het eerste backenddeel over. Voeg de route alleen toe wanneer jouw eigen server hem nog niet heeft. De HTML, CSS en browsercode hieronder heb je wel nodig voor het zichtbare detailscherm.
// backend/server.js
app.get('/api/students/:id', (req, res) => {
const student = db.prepare(`
SELECT
id,
first_name AS firstName,
last_name AS lastName,
email,
education
FROM students
WHERE id = ?
`)
.get(readId(req.params.id));
if (!student) throw httpError(404, 'Student niet gevonden.');
res.json(student);
});
// frontend/app.js
async function loadStudent(id) {
const response = await fetch(`/api/students/${id}`);
const student = await response.json();
if (!response.ok) throw new Error(student.error);
return student;
}
async function openStudentDetail(id) {
const student = await loadStudent(id);
document.querySelector('#detail-name').textContent =
`${student.firstName} ${student.lastName}`;
document.querySelector('#detail-email').textContent = student.email;
document.querySelector('#detail-education').textContent = student.education;
document.querySelector('#student-detail-dialog').showModal();
}
document.addEventListener('click', (event) => {
const openButton = event.target.closest('[data-show-student]');
if (openButton) openStudentDetail(Number(openButton.dataset.showStudent));
if (event.target.matches('[data-close-detail]')) {
document.querySelector('#student-detail-dialog').close();
}
});detail-name, detail-email en detail-education zijn gelijk aan de JavaScript-selectors.Plak in: de knop in je rijtemplate in frontend/app.js; de <dialog> direct boven </main> in frontend/index.html.
<!-- In de HTML die je per studentenrij maakt: -->
<button class="detail-open" type="button" data-show-student="1">
Bekijk
</button>
<!-- Eén keer vóór </main> in frontend/index.html: -->
<dialog class="student-dialog" id="student-detail-dialog">
<button class="student-dialog__close" type="button" data-close-detail aria-label="Sluiten">×</button>
<p class="student-dialog__label">Studentdetails</p>
<h2 id="detail-name">Studentnaam</h2>
<dl>
<div><dt>E-mail</dt><dd id="detail-email">—</dd></div>
<div><dt>Opleiding</dt><dd id="detail-education">—</dd></div>
</dl>
</dialog>Plak in: frontend/style.css, helemaal onderaan.
.detail-open {
border: 0;
background: transparent;
color: #1e56dc;
font-weight: 700;
cursor: pointer;
}
.student-dialog {
width: min(520px, 90vw);
padding: 30px;
border: 0;
background: #ffffff;
color: #10233f;
box-shadow: 0 24px 70px rgba(16, 35, 63, 0.28);
}
.student-dialog::backdrop {
background: rgba(16, 35, 63, 0.65);
}
.student-dialog__close {
position: absolute;
top: 12px;
right: 12px;
width: 36px;
height: 36px;
border: 1px solid #d9dee8;
background: #ffffff;
cursor: pointer;
}
.student-dialog__label { color: #1e56dc; font-weight: 700; }
.student-dialog dl div { padding: 12px 0; border-top: 1px solid #d9dee8; }
.student-dialog dt { color: #647083; font-size: 12px; }
.student-dialog dd { margin: 3px 0 0; font-weight: 700; }05Gebruikers laten inloggenWachtwoorden veilig bewaren en ingelogd blijven.PHP
Eerst doen: voer bij recept U1 precies één users-tabel uit: SQLite óf MySQL. Maak daarna met het eerste blok hieronder één adminaccount. Het inloggen, blokkeren van inactieve accounts en uitloggen zijn daarna compleet.
<?php
declare(strict_types=1);
// EENMALIG: maak het allereerste adminaccount en verwijder dit bestand daarna.
require __DIR__ . '/config/database.php';
$db = database();
// WIJZIG ALLEEN DEZE TWEE WAARDEN.
$email = 'admin@voorbeeld.nl';
$password = 'VeranderDit123!';
if ((int) $db->query('SELECT COUNT(*) FROM users')->fetchColumn() > 0) {
exit('Er bestaat al een gebruiker. Verwijder maak-admin.php nu.');
}
$statement = $db->prepare(
'INSERT INTO users (email, password_hash, role, active) VALUES (?, ?, ?, 1)'
);
$statement->execute([
strtolower($email),
password_hash($password, PASSWORD_DEFAULT),
'admin',
]);
echo 'Admin is aangemaakt. Verwijder maak-admin.php nu.';<?php
declare(strict_types=1);
// LOGIN: start de sessie voordat er HTML wordt getoond.
session_start();
require __DIR__ . '/config/database.php';
require __DIR__ . '/includes/functions.php';
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
verify_csrf($_POST['_token'] ?? null);
$db = database();
$email = strtolower(trim($_POST['email'] ?? ''));
$password = (string) ($_POST['password'] ?? '');
$statement = $db->prepare('SELECT * FROM users WHERE email = ?');
$statement->execute([$email]);
$user = $statement->fetch();
// Geef bewust dezelfde fout bij een onbekend, inactief of fout account.
if (!$user || !(bool) $user['active'] || !password_verify($password, $user['password_hash'])) {
throw new RuntimeException('E-mail of wachtwoord klopt niet.');
}
session_regenerate_id(true);
$_SESSION['user_id'] = (int) $user['id'];
$_SESSION['role'] = $user['role'];
redirect('index.php');
} catch (Throwable $loginError) {
$error = $loginError->getMessage();
}
}
?><?php
declare(strict_types=1);
// UITLOGGEN: verwijder sessiegegevens, sessiecookie en de serversessie.
session_start();
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$cookie = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $cookie['path'], $cookie['domain'], $cookie['secure'], $cookie['httponly']);
}
session_destroy();
header('Location: login.php');
exit;email en password zijn gelijk aan de namen in het PHP-deel.Plak in: login.php, direct onder de afsluitende ?> van het PHP-blok.
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Inloggen</title>
<link rel="stylesheet" href="assets/app.css">
</head>
<body class="login-page">
<main class="login-card">
<p class="login-card__label">Beveiligde administratie</p>
<h1>Welkom terug</h1>
<p>Log in om de administratie te openen.</p>
<?php if ($error !== ''): ?>
<div class="login-error" role="alert"><?= e($error) ?></div>
<?php endif; ?>
<form class="login-form" method="post">
<input type="hidden" name="_token" value="<?= e(csrf_token()) ?>">
<label><span>E-mailadres</span><input name="email" type="email" autocomplete="email" required></label>
<label><span>Wachtwoord</span><input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit">Inloggen</button>
</form>
</main>
</body>
</html>Daarna in iedere beschermde pagina: zet deze link bijvoorbeeld in de navigatie.
<a class="logout-link" href="logout.php">Uitloggen</a>Plak in: assets/app.css, helemaal onderaan.
.login-page {
display: grid;
min-height: 100vh;
margin: 0;
padding: 24px;
place-items: center;
background: #f5f3ed;
}
.login-card {
width: min(430px, 100%);
padding: clamp(24px, 6vw, 42px);
border: 1px solid #d9dee8;
background: #ffffff;
box-shadow: 0 20px 55px rgba(16, 35, 63, 0.12);
}
.login-card__label { color: #1e56dc; font-size: 12px; font-weight: 700; text-transform: uppercase; }
.login-card h1 { margin: 0; }
.login-card > p:not(.login-card__label) { color: #647083; }
.login-form { display: grid; gap: 15px; margin-top: 24px; }
.login-form label { display: grid; gap: 6px; font-size: 12px; font-weight: 700; }
.login-form input { width: 100%; min-height: 46px; padding: 10px 12px; border: 1px solid #cbd2df; }
.login-form button { min-height: 46px; border: 0; background: #1e56dc; color: #ffffff; font-weight: 700; cursor: pointer; }
.login-error { margin-top: 18px; padding: 12px; border-left: 4px solid #bf2c35; background: #fff0f1; color: #8f2029; }
.logout-link { display: inline-grid; min-height: 40px; padding: 0 14px; place-items: center; border: 1px solid #cbd2df; background: #fff; color: #10233f; font-weight: 700; text-decoration: none; }Bescherm iedere pagina: zet direct na session_start(): if (empty($_SESSION['user_id'])) redirect('login.php');
06Een foto laten uploadenAlleen geldige JPG- en PNG-bestanden tot 2 MB.PHP
Formulier: voeg enctype="multipart/form-data" toe en gebruik <input type="file" name="foto" accept="image/png,image/jpeg">.
$file = $_FILES['foto'] ?? null;
if (!$file || $file['error'] !== UPLOAD_ERR_OK) {
throw new RuntimeException('Kies een geldige afbeelding.');
}
if ($file['size'] > 2 * 1024 * 1024) {
throw new RuntimeException('De afbeelding mag maximaal 2 MB zijn.');
}
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png'];
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
if (!isset($allowed[$mime])) {
throw new RuntimeException('Alleen JPG en PNG zijn toegestaan.');
}
$filename = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
$destination = __DIR__ . '/uploads/' . $filename;
if (!move_uploaded_file($file['tmp_name'], $destination)) {
throw new RuntimeException('Uploaden is niet gelukt.');
}
// Bewaar alleen $filename in de database.name="foto" is exact dezelfde naam als $_FILES['foto'] in PHP.Plak in: index.php, op de plek waar de gebruiker een foto mag kiezen.
<form class="upload-card" method="post" enctype="multipart/form-data">
<input type="hidden" name="_token" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="intent" value="upload_photo">
<div>
<p class="upload-card__label">Profielfoto</p>
<h3>Kies een JPG- of PNG-afbeelding</h3>
<p>Maximaal 2 MB.</p>
</div>
<label class="upload-card__field">
<span>Afbeelding kiezen</span>
<input type="file" name="foto" accept="image/png,image/jpeg" required>
</label>
<button type="submit">Foto uploaden</button>
</form>upload-card koppelt dit blok rechtstreeks aan de HTML.Plak in: assets/app.css, helemaal onderaan.
.upload-card {
display: grid;
gap: 18px;
max-width: 620px;
padding: 24px;
border: 1px dashed #91a3bc;
background: #ffffff;
}
.upload-card__label {
margin: 0;
color: #1e56dc;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.upload-card h3 { margin: 4px 0; }
.upload-card p { color: #647083; }
.upload-card__field { display: grid; gap: 7px; font-weight: 700; }
.upload-card__field input { padding: 12px; border: 1px solid #d9dee8; background: #f7f8fa; }
.upload-card button { min-height: 44px; border: 0; background: #1e56dc; color: #ffffff; font-weight: 700; cursor: pointer; }07Gegevens downloaden voor ExcelDownload het huidige overzicht als CSV-bestand.PHP
Nieuw bestand: export.php. Dit bestand mag vóór de headers geen HTML of spaties uitvoeren.
<?php
require __DIR__ . '/config/database.php';
$rows = database()->query(
'SELECT studentnummer, voornaam, achternaam, email, opleiding, status
FROM studenten ORDER BY achternaam'
)->fetchAll();
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="studenten.csv"');
$output = fopen('php://output', 'wb');
fputcsv($output, ['Studentnummer', 'Voornaam', 'Achternaam', 'E-mail', 'Opleiding', 'Status']);
foreach ($rows as $row) {
fputcsv($output, $row);
}
fclose($output);
exit;export.php start de download; hiervoor is geen formulier nodig.Plak in: index.php, bijvoorbeeld naast de knop “Nieuwe student”.
<a class="export-action" href="export.php" download>
<span aria-hidden="true">↓</span>
Download studenten als CSV
</a>.export-action is gelijk aan de linkclass.Plak in: assets/app.css, helemaal onderaan.
.export-action {
display: inline-flex;
gap: 9px;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0 15px;
border: 1px solid #1e56dc;
background: #ffffff;
color: #1e56dc;
font-weight: 700;
text-decoration: none;
}
.export-action:hover {
background: #1e56dc;
color: #ffffff;
}08Een lijst uit Excel toevoegenImporteer meerdere gecontroleerde CSV-rijen tegelijk.PHP
Formulier: gebruik method="post" enctype="multipart/form-data", een CSRF-token en een input met name="csv_bestand". De kolomvolgorde moet exact overeenkomen.
verify_csrf($_POST['_token'] ?? null);
$file = $_FILES['csv_bestand'] ?? null;
if (!$file || $file['error'] !== UPLOAD_ERR_OK) {
throw new RuntimeException('Kies een geldig CSV-bestand.');
}
$handle = fopen($file['tmp_name'], 'rb');
$expected = ['studentnummer', 'voornaam', 'achternaam', 'email', 'opleiding', 'status'];
$header = fgetcsv($handle);
if ($header !== $expected) {
fclose($handle);
throw new RuntimeException('De CSV-kolommen staan niet in de juiste volgorde.');
}
$insert = $db->prepare(
'INSERT INTO studenten
(studentnummer, voornaam, achternaam, email, opleiding, status, land_id)
VALUES (?, ?, ?, ?, ?, ?, ?)'
);
$db->beginTransaction();
try {
while (($row = fgetcsv($handle)) !== false) {
if (count($row) !== 6 || !filter_var($row[3], FILTER_VALIDATE_EMAIL)) {
throw new RuntimeException('Een CSV-rij bevat ongeldige gegevens.');
}
$insert->execute([...$row, $standaardLandId]);
}
$db->commit();
} catch (Throwable $error) {
$db->rollBack();
throw $error;
} finally {
fclose($handle);
}csv_bestand is exact gelijk aan $_FILES['csv_bestand'] in PHP.Plak in: het nieuwe bestand import.php, onder het PHP-verwerkingsdeel.
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Studenten importeren</title>
<link rel="stylesheet" href="assets/app.css">
</head>
<body>
<main class="import-card">
<p class="import-card__label">CSV-import</p>
<h1>Meerdere studenten toevoegen</h1>
<p>Kolommen: studentnummer, voornaam, achternaam, email, opleiding en status.</p>
<form class="import-form" method="post" enctype="multipart/form-data">
<input type="hidden" name="_token" value="<?= e(csrf_token()) ?>">
<label>
<span>CSV-bestand</span>
<input type="file" name="csv_bestand" accept=".csv,text/csv" required>
</label>
<button type="submit">CSV controleren en importeren</button>
</form>
<a href="index.php">Terug naar het overzicht</a>
</main>
</body>
</html>import-card staat ook in de HTML.Plak in: assets/app.css, helemaal onderaan.
.import-card {
width: min(680px, 92vw);
margin: 60px auto;
padding: clamp(22px, 5vw, 40px);
border: 1px solid #d9dee8;
background: #ffffff;
box-shadow: 0 18px 48px rgba(16, 35, 63, 0.08);
}
.import-card__label { margin: 0; color: #1e56dc; font-size: 12px; font-weight: 700; text-transform: uppercase; }
.import-card h1 { margin: 5px 0 10px; }
.import-card > p { color: #647083; }
.import-form { display: grid; gap: 16px; margin: 25px 0 18px; }
.import-form label { display: grid; gap: 7px; font-weight: 700; }
.import-form input { padding: 12px; border: 1px solid #d9dee8; background: #f7f8fa; }
.import-form button { min-height: 46px; border: 0; background: #1e56dc; color: #ffffff; font-weight: 700; cursor: pointer; }Bepaal vóór dit blok een geldige $standaardLandId. Bij één fout wordt de hele import teruggedraaid.
09Een prullenbak makenVerberg gegevens en zet ze later eventueel terug.Database
Gebruik dit voor: archiveren, prullenbakken en gegevens die je later wilt terugzetten.
-- Voeg de kolom toe aan je CREATE TABLE:
deleted_at TEXT DEFAULT NULL
-- Soft delete:
UPDATE studenten SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?;
-- Normaal overzicht:
SELECT * FROM studenten WHERE deleted_at IS NULL ORDER BY id DESC;
-- Prullenbak:
SELECT * FROM studenten WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC;
-- Herstellen:
UPDATE studenten SET deleted_at = NULL WHERE id = ?;
-- Definitief verwijderen:
DELETE FROM studenten WHERE id = ? AND deleted_at IS NOT NULL;$trashIntents = [
'soft_delete_student',
'restore_student',
'delete_student_permanent',
];
if ($_SERVER['REQUEST_METHOD'] === 'POST'
&& in_array(post_text('intent'), $trashIntents, true)
) {
verify_csrf($_POST['_token'] ?? null);
$id = positive_id($_POST['id'] ?? null, 'Student-ID');
$intent = post_text('intent');
if ($intent === 'soft_delete_student') {
$statement = $db->prepare(
'UPDATE studenten SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?'
);
} elseif ($intent === 'restore_student') {
$statement = $db->prepare(
'UPDATE studenten SET deleted_at = NULL WHERE id = ?'
);
} else {
$statement = $db->prepare(
'DELETE FROM studenten WHERE id = ? AND deleted_at IS NOT NULL'
);
}
$statement->execute([$id]);
redirect('index.php#trash');
}
$trashedStudents = $db->query(
'SELECT * FROM studenten
WHERE deleted_at IS NOT NULL
ORDER BY deleted_at DESC'
)->fetchAll();intent-waarden zijn exact gelijk aan de PHP-acties hierboven.Plak in: het eerste formulier in iedere normale studentenrij; de sectie één keer vóór de footer in index.php.
<!-- In iedere normale studentenrij: -->
<form method="post">
<input type="hidden" name="_token" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="intent" value="soft_delete_student">
<input type="hidden" name="id" value="<?= e($student['id']) ?>">
<button class="trash-move" type="submit">Naar prullenbak</button>
</form>
<!-- Eén keer vóór de footer: -->
<section class="trash-panel" id="trash">
<div class="trash-panel__heading">
<p>Prullenbak</p>
<h2>Verwijderde studenten</h2>
</div>
<?php foreach ($trashedStudents as $student): ?>
<article class="trash-row">
<div>
<b><?= e($student['voornaam'] . ' ' . $student['achternaam']) ?></b>
<small><?= e($student['email']) ?></small>
</div>
<div class="trash-row__actions">
<form method="post">
<input type="hidden" name="_token" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="intent" value="restore_student">
<input type="hidden" name="id" value="<?= e($student['id']) ?>">
<button type="submit">Herstellen</button>
</form>
<form method="post" onsubmit="return confirm('Definitief verwijderen?')">
<input type="hidden" name="_token" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="intent" value="delete_student_permanent">
<input type="hidden" name="id" value="<?= e($student['id']) ?>">
<button class="is-danger" type="submit">Definitief verwijderen</button>
</form>
</div>
</article>
<?php endforeach; ?>
</section>Plak in: assets/app.css, helemaal onderaan.
.trash-panel {
margin-top: 48px;
padding: 24px;
border: 1px solid #d9dee8;
background: #ffffff;
}
.trash-panel__heading p { margin: 0; color: #1e56dc; font-size: 12px; font-weight: 700; text-transform: uppercase; }
.trash-panel__heading h2 { margin: 4px 0 20px; }
.trash-row { display: flex; gap: 18px; align-items: center; justify-content: space-between; padding: 15px 0; border-top: 1px solid #e6e9ee; }
.trash-row > div:first-child { display: grid; }
.trash-row small { color: #647083; }
.trash-row__actions { display: flex; gap: 8px; }
.trash-row__actions button, .trash-move { min-height: 38px; padding: 0 12px; border: 1px solid #1e56dc; background: #ffffff; color: #1e56dc; font-weight: 700; cursor: pointer; }
.trash-row__actions .is-danger { border-color: #bf2c35; color: #bf2c35; }
@media (max-width: 620px) {
.trash-row, .trash-row__actions { align-items: stretch; flex-direction: column; }
}10Meerdere wijzigingen samen veilig opslaanAlles lukt, of alles wordt automatisch teruggedraaid.PHP
Gebruik dit voor: bestellingen + voorraad, inschrijving + vakken of uitleen + beschikbaar aantal.
// Laad de twee keuzelijsten voor het HTML-formulier.
$klanten = $db->query('SELECT id, naam FROM klanten ORDER BY naam')->fetchAll();
$producten = $db->query(
'SELECT id, naam, prijs FROM producten ORDER BY naam'
)->fetchAll();
// Verwerk alleen het bestelformulier uit dit recept.
if ($_SERVER['REQUEST_METHOD'] === 'POST'
&& post_text('intent') === 'create_order'
) {
verify_csrf($_POST['_token'] ?? null);
$klantId = positive_id($_POST['klant_id'] ?? null, 'Klant');
$productId = positive_id($_POST['product_id'] ?? null, 'Product');
$aantal = positive_id($_POST['aantal'] ?? null, 'Aantal');
$product = $db->prepare('SELECT prijs FROM producten WHERE id = ?');
$product->execute([$productId]);
$prijs = $product->fetchColumn();
if ($prijs === false) {
throw new RuntimeException('Product niet gevonden.');
}
$totaal = (float) $prijs * $aantal;
$db->beginTransaction();
try {
$order = $db->prepare(
'INSERT INTO bestellingen (klant_id, totaal) VALUES (?, ?)'
);
$order->execute([$klantId, $totaal]);
$stock = $db->prepare(
'UPDATE producten SET voorraad = voorraad - ?
WHERE id = ? AND voorraad >= ?'
);
$stock->execute([$aantal, $productId, $aantal]);
if ($stock->rowCount() !== 1) {
throw new RuntimeException('Niet genoeg voorraad.');
}
$db->commit();
redirect('index.php');
} catch (Throwable $error) {
$db->rollBack();
throw $error;
}
}$_POST in het PHP-blok.Plak in: index.php, in het zichtbare HTML-deel waar je een bestelling wilt invoeren.
<section class="order-card">
<div class="order-card__heading">
<p>Nieuwe bestelling</p>
<h2>Product reserveren</h2>
</div>
<form class="order-form" method="post">
<input type="hidden" name="_token" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="intent" value="create_order">
<label><span>Klant</span>
<select name="klant_id" required>
<?php foreach ($klanten as $klant): ?>
<option value="<?= e($klant['id']) ?>"><?= e($klant['naam']) ?></option>
<?php endforeach; ?>
</select>
</label>
<label><span>Product</span>
<select name="product_id" required>
<?php foreach ($producten as $product): ?>
<option value="<?= e($product['id']) ?>"><?= e($product['naam']) ?></option>
<?php endforeach; ?>
</select>
</label>
<label><span>Aantal</span><input name="aantal" type="number" min="1" value="1" required></label>
<button type="submit">Bestelling opslaan</button>
</form>
</section>order-card en order-form komen uit de HTML hierboven.Plak in: jouw gekoppelde CSS-bestand, bijvoorbeeld assets/app.css.
.order-card {
max-width: 720px;
padding: 26px;
border: 1px solid #d9dee8;
background: #ffffff;
box-shadow: 0 18px 48px rgba(16, 35, 63, 0.08);
}
.order-card__heading p { margin: 0; color: #1e56dc; font-size: 12px; font-weight: 700; text-transform: uppercase; }
.order-card__heading h2 { margin: 4px 0 22px; }
.order-form { display: grid; gap: 15px; }
.order-form label { display: grid; gap: 7px; color: #546178; font-size: 12px; font-weight: 700; }
.order-form input, .order-form select { width: 100%; min-height: 44px; padding: 10px 12px; border: 1px solid #cbd2df; background: #ffffff; }
.order-form button { min-height: 46px; border: 0; background: #1e56dc; color: #ffffff; font-weight: 700; cursor: pointer; }
@media (max-width: 620px) {
.order-card { padding: 18px; }
}F1Zoeken en filteren in een overzichtCombineer een zoekwoord en status zonder onveilige SQL.CRUD + PHP
CRUD-functie: dit breidt Read uit. Prepared parameters houden de zoekterm buiten de SQL-code.
// FILTERS: lees alleen bekende waarden uit de URL.
$search = trim((string) ($_GET['search'] ?? ''));
$status = (string) ($_GET['status'] ?? 'all');
$allowedStatuses = ['all', 'actief', 'inactief', 'afgestudeerd'];
if (!in_array($status, $allowedStatuses, true)) $status = 'all';
$where = [];
$parameters = [];
if ($search !== '') {
$where[] = '(voornaam LIKE :search OR achternaam LIKE :search OR email LIKE :search)';
$parameters['search'] = '%' . $search . '%';
}
if ($status !== 'all') {
$where[] = 'status = :status';
$parameters['status'] = $status;
}
$sql = 'SELECT * FROM studenten';
if ($where !== []) $sql .= ' WHERE ' . implode(' AND ', $where);
$sql .= ' ORDER BY achternaam, voornaam';
$statement = $db->prepare($sql);
$statement->execute($parameters);
$filteredStudents = $statement->fetchAll();search en status zijn gelijk aan PHP.Plak in: index.php, boven het studentenoverzicht.
<form class="list-tools" method="get">
<label><span>Zoeken</span><input name="search" type="search" value="<?= e($search) ?>" placeholder="Naam of e-mail"></label>
<label><span>Status</span><select name="status">
<option value="all">Alle statussen</option>
<option value="actief" <?= $status === 'actief' ? 'selected' : '' ?>>Actief</option>
<option value="inactief" <?= $status === 'inactief' ? 'selected' : '' ?>>Inactief</option>
<option value="afgestudeerd" <?= $status === 'afgestudeerd' ? 'selected' : '' ?>>Afgestudeerd</option>
</select></label>
<button type="submit">Toepassen</button><a href="index.php">Wissen</a>
</form>
<p class="list-tools__count"><?= count($filteredStudents) ?> resultaten</p>list-tools.Plak in: assets/app.css, onderaan.
/* FILTERBALK: zoeken, status en acties op één regel. */
.list-tools { display: grid; grid-template-columns: 1fr 220px auto auto; gap: 10px; align-items: end; padding: 16px; border: 1px solid #d9dee8; background: #fff; }
.list-tools label { display: grid; gap: 5px; font-size: 12px; font-weight: 700; }
.list-tools input, .list-tools select { min-height: 42px; padding: 9px 11px; border: 1px solid #cbd2df; }
.list-tools button, .list-tools a { display: grid; min-height: 42px; padding: 0 13px; place-items: center; border: 1px solid #1e56dc; background: #1e56dc; color: #fff; font-weight: 700; text-decoration: none; }
.list-tools__count { color: #647083; font-weight: 700; }
@media (max-width: 720px) { .list-tools { grid-template-columns: 1fr; align-items: stretch; } }F2Paginering voor grote overzichtenToon bijvoorbeeld twintig rijen per pagina.CRUD + PHP
CRUD-functie: Read blijft snel doordat SQL alleen de huidige pagina ophaalt.
// PAGINERING: bereken eerst veilige grenzen.
$perPage = 20;
$totalRows = (int) $db->query('SELECT COUNT(*) FROM studenten')->fetchColumn();
$pageCount = max(1, (int) ceil($totalRows / $perPage));
$currentPage = filter_var($_GET['p'] ?? 1, FILTER_VALIDATE_INT) ?: 1;
$currentPage = min(max(1, $currentPage), $pageCount);
$offset = ($currentPage - 1) * $perPage;
$statement = $db->prepare(
'SELECT * FROM studenten ORDER BY id DESC LIMIT :limit OFFSET :offset'
);
$statement->bindValue('limit', $perPage, PDO::PARAM_INT);
$statement->bindValue('offset', $offset, PDO::PARAM_INT);
$statement->execute();
$students = $statement->fetchAll();p wordt door PHP begrensd.Plak in: direct onder de studententabel.
<nav class="pagination-bar" aria-label="Resultaatpagina's">
<span>Pagina <?= $currentPage ?> van <?= $pageCount ?></span>
<div>
<?php for ($page = 1; $page <= $pageCount; $page++): ?>
<a class="<?= $page === $currentPage ? 'is-current' : '' ?>" href="?p=<?= $page ?>"><?= $page ?></a>
<?php endfor; ?>
</div>
</nav>pagination-bar staat in de HTML.Plak in: assets/app.css, onderaan.
/* PAGINERING: status links, aanklikbare pagina's rechts. */
.pagination-bar { display: flex; gap: 16px; align-items: center; justify-content: space-between; margin-top: 18px; }
.pagination-bar > span { color: #647083; font-size: 12px; font-weight: 700; }
.pagination-bar > div { display: flex; gap: 6px; flex-wrap: wrap; }
.pagination-bar a { display: grid; width: 38px; height: 38px; place-items: center; border: 1px solid #cbd2df; background: #fff; text-decoration: none; }
.pagination-bar a.is-current { border-color: #1e56dc; background: #1e56dc; color: #fff; }
@media (max-width: 560px) { .pagination-bar { align-items: flex-start; flex-direction: column; } }F3Status en bulkactiesWijzig meerdere gekozen rijen tegelijk.CRUD + PHP
CRUD-functie: Update of Delete voor meerdere ids. De backend accepteert maximaal honderd geldige positieve ids.
// BULKACTIES: valideer ieder gekozen id en bouw veilige placeholders.
$bulkIntents = ['bulk_activate', 'bulk_deactivate', 'bulk_delete'];
if ($_SERVER['REQUEST_METHOD'] === 'POST'
&& in_array(post_text('intent'), $bulkIntents, true)
) {
verify_csrf($_POST['_token'] ?? null);
$ids = array_values(array_unique(array_filter(
array_map('intval', $_POST['selected_ids'] ?? []),
fn (int $id): bool => $id > 0
)));
if ($ids === [] || count($ids) > 100) {
throw new RuntimeException('Kies tussen één en honderd geldige rijen.');
}
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$intent = post_text('intent');
if ($intent === 'bulk_delete') {
$sql = "DELETE FROM studenten WHERE id IN ($placeholders)";
$parameters = $ids;
} else {
$status = $intent === 'bulk_activate' ? 'actief' : 'inactief';
$sql = "UPDATE studenten SET status = ? WHERE id IN ($placeholders)";
$parameters = [$status, ...$ids];
}
$db->prepare($sql)->execute($parameters);
redirect('index.php');
}selected_ids[] stuurt een lijst naar PHP.Plak in: als alternatief overzicht in index.php; plaats geen andere formulieren binnen dit formulier.
<form class="bulk-tools" method="post">
<input type="hidden" name="_token" value="<?= e(csrf_token()) ?>">
<div class="bulk-tools__bar">
<b>Met geselecteerde rijen:</b>
<button name="intent" value="bulk_activate" type="submit">Actief maken</button>
<button name="intent" value="bulk_deactivate" type="submit">Inactief maken</button>
<button class="is-danger" name="intent" value="bulk_delete" type="submit" onclick="return confirm('Geselecteerde rijen verwijderen?')">Verwijderen</button>
</div>
<div class="bulk-tools__rows">
<?php foreach ($students as $student): ?>
<label><input type="checkbox" name="selected_ids[]" value="<?= e($student['id']) ?>"><span><b><?= e($student['voornaam'] . ' ' . $student['achternaam']) ?></b><small><?= e($student['status']) ?></small></span></label>
<?php endforeach; ?>
</div>
</form>bulk-tools.Plak in: assets/app.css, onderaan.
/* BULKACTIES: actiebalk boven een lijst met selectievakjes. */
.bulk-tools { border: 1px solid #d9dee8; background: #fff; }
.bulk-tools__bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; padding: 14px; background: #f5f7fa; }
.bulk-tools__bar button { min-height: 38px; padding: 0 11px; border: 1px solid #1e56dc; background: #fff; color: #1e56dc; font-weight: 700; }
.bulk-tools__bar .is-danger { border-color: #b62935; color: #b62935; }
.bulk-tools__rows label { display: flex; gap: 12px; align-items: center; padding: 13px 16px; border-top: 1px solid #e6e9ee; cursor: pointer; }
.bulk-tools__rows label:has(input:checked) { background: #edf2ff; }
.bulk-tools__rows input { width: 18px; height: 18px; }
.bulk-tools__rows span { display: grid; }
.bulk-tools__rows small { color: #647083; }F4Activiteitenlog en wijzigingsgeschiedenisBewaar wie wat heeft toegevoegd, gewijzigd of verwijderd.Users + DB
Voor een complete applicatie: schrijf na iedere geslaagde CRUD-actie één logregel. Bewaar nooit wachtwoorden of volledige gevoelige records in details.
-- ACTIVITEITENLOG: één rij per geslaagde wijziging.
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
action TEXT NOT NULL,
entity TEXT NOT NULL,
entity_id INTEGER,
details TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
);-- MYSQL · ACTIVITEITENLOG MET DEZELFDE VELDEN.
CREATE TABLE IF NOT EXISTS audit_log (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NULL,
action VARCHAR(40) NOT NULL,
entity VARCHAR(80) NOT NULL,
entity_id INT UNSIGNED NULL,
details LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_audit_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_audit_created (created_at),
INDEX idx_audit_entity (entity, entity_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;// In includes/functions.php:
function audit_log(PDO $db, string $action, string $entity, ?int $entityId, array $details = []): void
{
$statement = $db->prepare(
'INSERT INTO audit_log (user_id, action, entity, entity_id, details)
VALUES (?, ?, ?, ?, ?)'
);
$statement->execute([
$_SESSION['user_id'] ?? null,
$action,
$entity,
$entityId,
$details === [] ? null : json_encode($details, JSON_UNESCAPED_UNICODE),
]);
}
// Voorbeeld direct na een geslaagde UPDATE in index.php:
audit_log($db, 'update', 'student', $id, ['gewijzigd' => ['email', 'status']]);
// READ: laad de nieuwste honderd regels vóór de HTML:
$auditRows = $db->query(
'SELECT audit_log.*, users.email AS user_email
FROM audit_log
LEFT JOIN users ON users.id = audit_log.user_id
ORDER BY audit_log.id DESC LIMIT 100'
)->fetchAll();$auditRows.Plak in: een adminpagina of onderaan index.php.
<section class="audit-log">
<header><p>Controle</p><h2>Activiteitenlog</h2></header>
<ol>
<?php foreach ($auditRows as $log): ?>
<li>
<span class="audit-log__action"><?= e($log['action']) ?></span>
<div><b><?= e($log['entity']) ?> #<?= e($log['entity_id'] ?? '—') ?></b><small>door <?= e($log['user_email'] ?? 'verwijderde gebruiker') ?></small></div>
<time><?= e($log['created_at']) ?></time>
</li>
<?php endforeach; ?>
</ol>
</section>audit-log staat in de HTML.Plak in: assets/app.css, onderaan.
/* ACTIVITEITENLOG: nieuwste wijziging bovenaan. */
.audit-log { margin-top: 40px; padding: 24px; border: 1px solid #d9dee8; background: #fff; }
.audit-log header p { margin: 0; color: #1e56dc; font-weight: 700; text-transform: uppercase; }
.audit-log header h2 { margin: 4px 0 20px; }
.audit-log ol { margin: 0; padding: 0; list-style: none; }
.audit-log li { display: grid; grid-template-columns: 90px 1fr auto; gap: 12px; align-items: center; padding: 13px 0; border-top: 1px solid #e6e9ee; }
.audit-log__action { padding: 4px 7px; background: #edf2ff; color: #1e56dc; font-size: 11px; font-weight: 700; text-transform: uppercase; }
.audit-log li div { display: grid; }
.audit-log small, .audit-log time { color: #647083; font-size: 11px; }
@media (max-width: 620px) { .audit-log li { grid-template-columns: 1fr; } }Waar hoort een snippet?
Van basis-CRUD naar echte applicatie.
De PHP-starters bevatten alle zes functies hieronder. De JavaScript-starter bevat zoeken, filteren, relaties en dashboardcijfers; sorteren en paginering kun je daarna met dezelfde querypatronen toevoegen.
Zoeken
Zoek tegelijk op studentnummer, naam, e-mail en opleiding met LIKE.
Filteren
Toon alleen actieve studenten of alleen studenten uit één land.
Sorteren
Een vaste allowlist voorkomt dat ongecontroleerde tekst in ORDER BY komt.
Paginering
LIMIT en OFFSET houden grote overzichten snel en leesbaar.
Relaties
Een foreign key verbindt studenten aan landen en voorkomt kapotte verwijzingen.
Dashboard
Kleine COUNT(*)-queries geven direct aantallen voor het overzicht.
Bonus: CSV-export toevoegen
Maak een aparte actie die headers instelt, alle studenten ophaalt en iedere rij met fputcsv() schrijft. Doe dit vóórdat er HTML is verstuurd.
header('Content-Type: text/csv; charset=utf-8');Maak het van jou, zonder iets te missen.
Een CRUD voor producten, boeken of voertuigen gebruikt exact dezelfde structuur. Je verandert vooral de namen en velden. Kies hieronder een thema om de vertaling te zien.
studentstudentenstudentnummer, naam, e-mailcreate_studentupdate_studentdelete_…idstudentencreated_at, updated_atDe veilige hernoemvolgorde
- 1
Database eerst. Wijzig tabelnaam, kolommen en eventuele foreign keys in
schema.sql. - 2
PHP-verwerking. Pas de inputfunctie, INSERT, SELECT, UPDATE en DELETE aan.
- 3
Formulier. Laat iedere
name="..."exact overeenkomen met wat PHP leest. - 4
Overzicht. Vervang tabelkoppen en de velden in de
foreach-lus. - 5
Reset en test. Verwijder tijdens ontwikkeling
app.sqlitezodat je nieuwe schema schoon start.
student zit ook in woorden als studentnummer. Werk per bestand en controleer na iedere stap.
Zoek op wat jij in beeld ziet.
Kopieer alleen het herkenbare deel van je foutmelding. De foutzoeker gebruikt simpele woorden, dus driver of locked is vaak al genoeg.
11 oplossingen
PDOcould not find driverPHP ziet de SQLite-extensie niet.
Open het actieve php.ini-bestand via XAMPP. Haal de puntkomma weg voor extension=pdo_sqlite en extension=sqlite3. Sla op en herstart Apache.
Controle: maak een pagina met phpinfo(); en zoek daarin naar pdo_sqlite.
SQLno such table: studentenJe schema is niet uitgevoerd of je opent een ander bestand.
Controleer het pad naar schema.sql in database.php. Verwijder daarna tijdens ontwikkeling data/app.sqlite en laad de pagina opnieuw.
SQLdatabase is lockedEen ander proces houdt het SQLite-bestand vast.
Sluit een SQLite-browser die het bestand bewerkt, wacht kort en probeer opnieuw. De starter gebruikt al PRAGMA busy_timeout = 5000. Synchroniseer de datamap niet live met cloudsoftware.
SQLattempt to write a readonly databaseApache mag niet schrijven in de datamap.
Zorg dat de map data en het bestand app.sqlite niet op alleen-lezen staan. Plaats het project in htdocs, niet in een beschermde systeemmap.
PHPheaders already sentEr is al tekst verstuurd vóór de redirect.
Zet de POST-verwerking en header('Location: ...') vóór alle HTML. Controleer ook dat er niets vóór de eerste <?php staat.
SQLUNIQUE constraint failedEen waarde die uniek moet zijn bestaat al.
Gebruik een ander studentnummer, e-mailadres of een andere landcode. Verwijder UNIQUE niet zomaar: die regel beschermt je data.
SQLFOREIGN KEY constraint failedJe verwijdert een rij waar andere gegevens nog naar verwijzen.
Een land kan niet weg zolang studenten dat land gebruiken. Wijzig eerst die studenten naar een ander land of verwijder ze.
HTTP404 Not Found op localhostApache vindt je map of bestand niet.
Controleer of Apache groen is, de map echt in C:\xampp\htdocs\studenten-crud staat en je http://localhost/studenten-crud/ opent.
MYSQLAccess denied for user 'root'De MySQL-inloggegevens komen niet overeen.
Controleer of MySQL groen is in XAMPP. Pas daarna $username en $password in config/database.php aan. Een standaard lokale XAMPP-installatie gebruikt vaak root met een leeg wachtwoord.
NODECannot find module 'express'De JavaScript-packages zijn nog niet geïnstalleerd.
Open een terminal in de map waar package.json staat en voer npm install uit. Start daarna opnieuw met npm start.
FETCHFailed to fetch / API reageert nietDe frontend kan de Express-server niet bereiken.
Controleer of npm start nog draait en open de app via http://localhost:3000. Open frontend/index.html niet rechtstreeks en gebruik voor deze starter geen aparte Live Server-poort.