3 complete routes · voor beginners

Van lege map naar werkende CRUD.

Bouw een complete studentenadministratie met PHP of JavaScript en verander die daarna in ieder thema dat je wilt. Elke stap legt uit wat je plakt, waar het hoort en waarom het werkt.

Ervaring nodig
Bijna geen
Routes
PHP of JavaScript
Databases
SQLite of MySQL
Het herhaalbare patroon
Technische illustratie van een formulier, tabel, database en code die met elkaar verbonden zijn
Create Read Update Delete

Geen losse snippets. Eén route van installatie tot werkende applicatie.

Hoofdstuk 01

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.

Start hier als je nul ervaring hebt

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.

  1. 1
    Kies één routeVoor je eerste project: PHP + SQLite.
  2. 2
    Download de starterBegin niet met een lege map.
  3. 3
    Start hem zonder wijzigingenControleer eerst dat het voorbeeld werkt.
  4. 4
    Verander éé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
BestandEen document met code, bijvoorbeeld index.php.
MapEen plek waarin je projectbestanden samen staan.
DatabaseDe digitale kast waarin je gegevens bewaart.
ServerHet programma dat je app uitvoert. Lokaal is dat XAMPP of Node.js.
TerminalEen venster waarin je een korte opdracht plakt en op Enter drukt.
SnippetEen kant-en-klaar stukje code voor één functie.
Eindresultaat
StudentenToevoegen, bekijken, wijzigen, verwijderen
OverzichtZoeken, filteren, sorteren, pagina’s
LandkeuzeIedere student kan aan één land worden gekoppeld
BeschermingVerkeerde invoer wordt gestopt en verwijderen wordt gecontroleerd
1FormulierHTML vraagt invoer
2PHPcontroleert en verwerkt
3SQLitebewaart de gegevens
4Overzichttoont het resultaat
Hoofdstuk 02

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.

Snelle keuzehulpWat wil je vooral?

Klik op wat het beste bij je past. Je krijgt meteen een aanbevolen 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-sqlite3

Deze 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.

Interactieve tool

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.

Niet je eerste stap
Laat eerst de studenten-starter ongewijzigd werken.

Kom daarna hier terug om “student” te veranderen in jouw eigen onderwerp. Zo weet je dat een fout door je laatste wijziging komt.

Kies eerst je route →
Welke CRUD-functionaliteiten wil je?

Vink alleen aan wat jouw applicatie echt nodig heeft. De PHP, API, HTML en JavaScript worden overal samen aangepast.

Gekozen: Create, Read, Update en Delete.

Welke gegevens wil je invullen?Kies het invoertype; PHP, JavaScript en de database krijgen automatisch het passende datatype.

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.

Voorbeeld van de automatische koppeling
Heel getalHTML numberPHP intJavaScript numberSQL INTEGER / INT
Prijs / decimaalHTML numberPHP floatJavaScript numberSQL REAL / DECIMAL
Ja / neeHTML checkboxPHP bool → 0/1JavaScript boolean → 0/1SQL INTEGER / TINYINT
PHP-typecheck

var 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.

Direct testen · begin hier

Complete PHP-app in één bestand

Kopieer tab 1 en volg deze stappen.

    database/schema.sql
    !
    Wil je direct testen? Begin met tab 1.

    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.

    Hoofdstuk 03

    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.

    1. 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.

    2. 02

      Controleer de juiste projectmap

      Je projecten staan normaal in C:\xampp\htdocs\. Maak daar straks de map studenten-crud.

    3. 03

      Controleer SQLite

      Open XAMPP → Apache → Config → PHP (php.ini). Zoek de volgende regels en haal de puntkomma vooraan weg.

      php.ini
      extension=pdo_sqlite
      extension=sqlite3

      Daarna: sla php.ini op en herstart Apache. Zonder herstart ziet PHP de wijziging niet.

    4. 04

      Doe de 10-seconden test

      Maak tijdelijk test.php in htdocs met onderstaande code. Zie je SQLite werkt? Dan kun je verder.

      <?php echo extension_loaded('pdo_sqlite') ? 'SQLite werkt' : 'SQLite ontbreekt';
    !
    SQLite en MySQL zijn alternatieven.

    SQLite is één bestand en het makkelijkst. MySQL draait als XAMPP-service en kun je bekijken via http://localhost/phpmyadmin/.

    Hoofdstuk 04

    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.

    C:\xampp\htdocs\studenten-crud\
    studenten-crud/
    ├── index.php
    ├── assets/
    │   ├── app.css
    │   └── app.js
    ├── config/
    │   └── database.php
    ├── database/
    │   └── schema.sql
    ├── includes/
    │   └── functions.php
    └── data/
        └── (app.sqlite komt automatisch)
    index.php

    Verwerkt acties en bouwt de HTML-pagina.

    database.php

    Maakt één veilige PDO-verbinding met SQLite.

    schema.sql

    Beschrijft tabellen, velden, relaties en startdata.

    functions.php

    Bevat herbruikbare validatie- en beveiligingsfuncties.

    PHP zonder voorkennis

    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.

    studenten-crud/index.php

    De hoofdpagina en alle acties

    Veilige plakregel
      De drie plakmethodes

      “Plakken” betekent niet altijd hetzelfde.

      VeiligstHeel bestand vervangen

      Open het bestand, druk Ctrl A, plak de complete bestandscode en sla op. Gebruik dit als je niet weet hoe PHP in elkaar zit.

      Exact blokTussen twee zoekankers vervangen

      De gids geeft een beginregel en eindregel. Selecteer inclusief beide regels en plak het nieuwe blok één keer.

      Meerdere delenEen recept stap voor stap uitvoeren

      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.

      Sneller beginnen?

      De hele map staat al voor je klaar.

      Pak het ZIP-bestand uit in htdocs en open daarna http://localhost/studenten-crud/.

      Download starter.zip
      Frontend-basis · voor alle routes

      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.

      01 · HTMLWat staat er?

      Formulieren, knoppen, koppen en tabellen.

      <input>
      02 · CSSHoe ziet het eruit?

      Kleuren, ruimte, lettertypes en mobiele layout.

      .stack-form
      03 · LogicaWat gebeurt er?

      PHP of JavaScript leest de invoer en kiest een CRUD-actie.

      create
      04 · SQLWat blijft bewaard?

      SQLite of MySQL bewaart de gegevens in een tabel.

      INSERT
      Kies dezelfde techniek als jouw bouwroute

      Laat alleen jouw koppelstappen zien.

      PHP-route · drie bestanden

      Deze bestanden staan niet los van elkaar.

      index.php staat in de hoofdmap. Daarom begint het pad naar CSS en JavaScript met assets/.

      1. 1
        studenten-crud/index.phpBevat de HTML én PHP-functionaliteit.
      2. 2
        studenten-crud/assets/app.cssWordt vanuit index.php geladen voor de vormgeving.
      3. 3
        studenten-crud/assets/app.jsRegelt alleen menu en verwijderbevestiging; PHP doet de CRUD.

      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.

      Eerste veilige oefening

      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.

      Stap 1 · HTML

      Open index.php. Zoek <main class="app-shell"> en plak dit direct op de volgende regel.

      index.php · direct na <main class="app-shell">
      <section class="begin-kaart">
          <p>Mijn eerste eigen onderdeel</p>
          <h2>Welkom bij mijn administratie</h2>
      </section>
      Stap 2 · CSS

      Open assets/app.css, ga helemaal naar beneden en plak dit na de laatste regel.

      assets/app.css · helemaal onderaan
      .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;
      }
      Zo test je dit:Sla beide bestanden op → open http://localhost/studenten-crud/ → druk Ctrl F5. Zie je het witte blok met een blauwe lijn? Dan zijn HTML en CSS goed gekoppeld.
      Van formulier naar scherm

      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.

      1 · HTML-formuliername="voornaam"index.php
      2 · PHP leestrequired_text('voornaam')functions.php
      3 · SQL bewaart:voornaam → voornaamindex.php + database
      4 · HTML toont$student['voornaam']index.php

      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 ACtrl V.

      index.php
      laden…
      Bestand wordt geladen…
      !
      Een class en een id hebben verschillende taken.

      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.

      Hoofdstuk 05

      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.

      PHPdatabase()vraagt een verbinding
      PDOsqlite:opent het bestand
      Bestandapp.sqlitebewaart alle rijen
      Belangrijk begrip

      PDO is de standaard PHP-laag waarmee je databasevragen uitvoert. Een prepared statement houdt gebruikersinvoer apart van SQL en voorkomt SQL-injectie.

      config/database.php · kern
      $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));
      Hoofdstuk 06 · alternatief

      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.

      SQLiteEén bestanddata/app.sqlite
      MySQLLokale serviceTe bekijken via phpMyAdmin
      Wat blijft gelijk?PDO + prepared statementsJe CRUD-query's blijven herkenbaar
      1. 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.

      2. 02

        Gebruik de MySQL-starter

        De starter gebruikt lokaal de standaard XAMPP-login: gebruiker root met een leeg wachtwoord. Heb jij een wachtwoord ingesteld, wijzig dat bovenaan config/database.php.

      3. 03

        Laat PHP de database maken

        Bij de eerste paginaweergave maakt PHP automatisch de database studenten_crud, de tabellen en vijf voorbeeldlanden.

      config/database.php · MySQL-kern
      $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');
      Houd het simpel

      Eerst verbinding, tabel, toevoegen en overzicht.

      Loginrollen, repositories en frameworks zijn niet nodig om je eerste CRUD te begrijpen.

      Plus voor later

      Voeg beveiliging en structuur in lagen toe.

      De download bevat prepared statements, validatie en CSRF al veilig, met comments erbij.

      config/database.php
      laden…
      Bestand wordt geladen…
      Route B · compleet

      PHP + MySQL starter

      Uitpakken in htdocs, Apache en MySQL starten, pagina openen.

      Download MySQL-starter
      Hoofdstuk 07 · PHP-route

      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.

      Voorbeeld: een blok vervangen

      Zo gebruik je de zoekankers.

      Je zoekt niet naar een regelnummer, want regelnummers veranderen. Je zoekt naar tekst die letterlijk in jouw starter staat.

      1 · ZoekDruk Ctrl Fcase 'create_student':De editor springt naar het begin van Create.
      2 · SelecteerVan beginanker tot eindankerbreak;Neem de eerste break; na het beginanker mee.
      3 · VervangPlak precies één keerCtrl V · Ctrl SJe voegt dus geen tweede Create-blok toe.
      01

      Create — een student toevoegen

      Het formulier verstuurt een POST-request. PHP valideert de velden en voert daarna één INSERT uit.

      index.php · CREATE · compleet vervangblok
      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;
      Waarom POST?

      Omdat de actie gegevens verandert.

      Waarom dubbele punten?

      :voornaam is een veilige placeholder.

      Wat komt terug?

      Na opslaan redirect PHP naar het overzicht.

      Testcheck na ieder onderdeel

      1. Voeg één student toe.
      2. Vernieuw de pagina: blijft de student staan?
      3. Wijzig de achternaam.
      4. Verwijder de teststudent.
      PHP + SQLite codebank

      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.

      Veilige copy-paste-modus

      Een compleet bestand vervangen in vijf klikken.

      1. 1

        Klik links op het bestand dat je wilt vervangen.

      2. 2

        Klik rechtsboven op Kopieer bestand.

      3. 3

        Open in VS Code exact hetzelfde pad in jouw project.

      4. 4

        Druk Ctrl A en daarna Ctrl V.

      5. 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.

      index.php
      laden…
      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.

      Hoofdstuk 08 · JavaScript-route

      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.

      Frontendapp.jsfetch('/api/students')
      BackendExpressPOST /api/students
      DatabaseSQLiteINSERT INTO students
      AntwoordJSON{ id, firstName, … }
      Verschil met PHP

      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.

      1. 01

        Installeer Node.js en de packages

        Open een terminal in de startermap. npm install leest package.json en installeert Express en better-sqlite3.

        npm install
      2. 02

        Start de backend

        npm start opent de SQLite-database, maakt tabellen, registreert API-routes en serveert de frontend op poort 3000.

        npm start
      3. 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/
      javascript-sqlite-crud/
      ├── package.json
      ├── backend/
      │   └── server.js
      ├── frontend/
      │   ├── index.html
      │   ├── style.css
      │   └── app.js
      └── data/
          └── app.sqlite
      server.js

      Express, SQLite, validatie en alle API-routes.

      index.html

      Formulieren, filters, tabellen en dashboard.

      app.js

      fetch, state, renderfuncties en events.

      app.sqlite

      Wordt 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.

      backend/server.js · database
      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.

      backend/server.js · CREATE API
      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.

      frontend/app.js · fetch
      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();
      }
      ActieMethodeURLSQL
      LijstGET/api/studentsSELECT
      ToevoegenPOST/api/studentsINSERT
      WijzigenPUT/api/students/:idUPDATE
      VerwijderenDELETE/api/students/:idDELETE

      Volledige 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.

      Complete bestanden

      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.

      backend/server.js
      laden…
      Bestand wordt geladen…
      Route C · compleet

      JavaScript + SQLite starter

      Na uitpakken: npm install, npm start, open poort 3000.

      Download JavaScript-starter
      !
      Open de frontend niet als los bestand.

      De API bestaat alleen terwijl node backend/server.js draait. Gebruik altijd http://localhost:3000.

      Universele snippetbibliotheek

      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.

      1FunctionaliteitDatabase, PHP of JavaScript
      2HTMLFormulier, knop of overzicht
      3CSSDe bijpassende vormgeving
      Hiermee bouw je goedAdministraties, registers, voorraad, reserveringen, catalogi en dashboards
      Vraagt een aparte specialistische routeWebshops met betalingen, realtime chat, videoplatforms en sociale netwerken
      Functiekiezer

      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.

      1

      Kies hierboven één functie. Voeg extra functies pas toe wanneer Create, Read, Update en Delete al werken.

      Complete-app volgorde

      Voeg niet alles tegelijk toe.

      Maak na iedere rij hieronder één testaccount of testrecord. Ga pas verder als die stap werkt.

      1. 1. BasisGenerator: Create, Read, Update en Delete
      2. 2. GebruikersLogin → gebruikersbeheer → rollen → profiel
      3. 3. OverzichtZoeken → filteren → paginering → bulkacties
      4. 4. ControlePrullenbak → activiteitenlog → CSV export
      Ik wil zelf verschillende soorten velden kiezenOpen het technische overzicht alleen wanneer je eigen velden gaat toevoegen.
      Wat wil je opslaan?FormulierSQLiteMySQLControle
      Korte teksttextTEXTVARCHAR(160)verplicht + maximale lengte
      Lange beschrijvingtextareaTEXTTEXTspaties verwijderen + maximale lengte
      Heel getalnumberINTEGERINTcontroleer of het een heel getal is
      Prijs / decimaalnumber step=.01REALDECIMAL(10,2)mag niet lager dan 0 zijn
      DatumdateTEXTDATEformaat jaar-maand-dag
      Ja / neecheckboxINTEGERTINYINT(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.

      schema.sql · foreign key
      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;
      2
      HTML · laat de gebruiker een land kiezenDe inputnaam land_id is dezelfde naam die de databasekoppeling gebruikt.

      Plak in: index.php, binnen het studentenformulier op de plek waar het land moet verschijnen.

      index.php · HTML-landkeuze
      <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>
      3
      CSS · geef de landkeuze een duidelijke vormDe selector .relation-select is gelijk aan de HTML-class hierboven.

      Plak in: assets/app.css, helemaal onderaan.

      assets/app.css · landkeuze
      .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.

      schema.sql · SQLite-gebruikerstabel
      -- 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-schema.sql · MySQL-gebruikerstabel
      -- 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;
      index.php · gebruikers-CRUD in PHP
      // 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();
      2
      HTML · adminformulier en gebruikersoverzichtAlle 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.

      index.php · HTML-gebruikersbeheer
      <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>
      3
      CSS · beheerformulier en rijenDe hoofdclass user-admin staat in de HTML.

      Plak in: assets/app.css, onderaan.

      assets/app.css · gebruikersbeheer
      /* 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.

      functions.php · rollen en rechten
      // 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.
      2
      HTML · toon alleen toegestane navigatiePHP bepaalt wat zichtbaar is; de backendcontrole blijft verplicht.

      Plak in: de navigatie van index.php.

      index.php · HTML-rechtenmenu
      <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>
      3
      CSS · navigatie en rolbadgeDe class role-access koppelt CSS en HTML.

      Plak in: assets/app.css, onderaan.

      assets/app.css · rechtenmenu
      /* 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.

      profile.php · profielacties
      // 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();
      2
      HTML · twee duidelijke profiel-formulierenPersoonsgegevens en wachtwoord blijven aparte acties.

      Plak in: profile.php, onder het PHP-deel.

      profile.php · HTML-profiel
      <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>
      3
      CSS · profielkaartenBeide formulieren delen de hoofdclass profile-settings.

      Plak in: assets/app.css, onderaan.

      assets/app.css · profiel
      /* 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.

      schema.sql · koppeltabel
      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');
      index.php · PHP-vakken opslaan
      $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;
          }
      }
      2
      HTML · laat meerdere vakken aanvinkenvak_ids[] stuurt alle gekozen ids als één lijst naar PHP.

      Plak in: index.php, in het zichtbare HTML-deel vóór de footer.

      index.php · HTML-vakkenkiezer
      <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>
      3
      CSS · maak van ieder vak een aanklikbare kaartAlle selectors beginnen met dezelfde HTML-class subject-picker.

      Plak in: assets/app.css, helemaal onderaan.

      assets/app.css · vakkenkiezer
      .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.

      detail.php
      <?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.');
      }
      ?>
      2
      HTML · bouw de zichtbare detailpaginaDit deel gebruikt precies de variabele $student uit het PHP-blok.

      Plak in: hetzelfde nieuwe bestand detail.php, direct onder de afsluitende ?>.

      detail.php · volledige HTML
      <!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>
      3
      CSS · maak een rustige detailkaartdetail.php laadt hetzelfde assets/app.css-bestand als de hoofdapp.

      Plak in: assets/app.css, helemaal onderaan.

      assets/app.css · studentdetail
      .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.

      Express + browser
      // 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();
        }
      });
      2
      HTML · voeg een detailknop en dialoog toeDe ids 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.

      frontend · HTML-detaildialoog
      <!-- 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>
      3
      CSS · vorm de knop en detaildialoogDe CSS gebruikt dezelfde classes als het HTML-blok.

      Plak in: frontend/style.css, helemaal onderaan.

      frontend/style.css · detaildialoog
      .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.

      maak-admin.php · eenmalig eerste account
      <?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.';
      login.php · complete inlogverwerking
      <?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();
          }
      }
      
      ?>
      logout.php · veilig uitloggen
      <?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;
      2
      HTML · maak het inlogschermDe formuliervelden email en password zijn gelijk aan de namen in het PHP-deel.

      Plak in: login.php, direct onder de afsluitende ?> van het PHP-blok.

      login.php · volledige HTML
      <!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.

      index.php · HTML-uitloglink
      <a class="logout-link" href="logout.php">Uitloggen</a>
      3
      CSS · centreer een professioneel inlogschermDe classes komen letterlijk uit de HTML hierboven.

      Plak in: assets/app.css, helemaal onderaan.

      assets/app.css · inlogscherm
      .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">.

      upload.php · verwerking
      $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.
      2
      HTML · maak een echt uploadformuliername="foto" is exact dezelfde naam als $_FILES['foto'] in PHP.

      Plak in: index.php, op de plek waar de gebruiker een foto mag kiezen.

      index.php · HTML-uploadformulier
      <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>
      3
      CSS · maak de uploadzone herkenbaarDe class upload-card koppelt dit blok rechtstreeks aan de HTML.

      Plak in: assets/app.css, helemaal onderaan.

      assets/app.css · uploadformulier
      .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.

      export.php
      <?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;
      2
      HTML · voeg een duidelijke downloadactie toeEen gewone link naar export.php start de download; hiervoor is geen formulier nodig.

      Plak in: index.php, bijvoorbeeld naast de knop “Nieuwe student”.

      index.php · HTML-downloadknop
      <a class="export-action" href="export.php" download>
          <span aria-hidden="true">↓</span>
          Download studenten als CSV
      </a>
      3
      CSS · laat de downloadactie bij je applicatie passenDe CSS-selector .export-action is gelijk aan de linkclass.

      Plak in: assets/app.css, helemaal onderaan.

      assets/app.css · CSV-download
      .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.

      import.php · transactie
      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);
      }
      2
      HTML · maak het CSV-importformuliercsv_bestand is exact gelijk aan $_FILES['csv_bestand'] in PHP.

      Plak in: het nieuwe bestand import.php, onder het PHP-verwerkingsdeel.

      import.php · volledige HTML
      <!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>
      3
      CSS · maak de importstap rustig en duidelijkDe hoofdclass import-card staat ook in de HTML.

      Plak in: assets/app.css, helemaal onderaan.

      assets/app.css · CSV-import
      .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.

      schema + queries
      -- 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;
      index.php · PHP-prullenbakacties
      $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();
      2
      HTML · voeg “Naar prullenbak” en het prullenbakoverzicht toeDe drie 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.

      index.php · HTML-prullenbak
      <!-- 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>
      3
      CSS · maak herstel en definitief verwijderen duidelijk verschillendDe prullenbak gebruikt uitsluitend classes uit het HTML-blok.

      Plak in: assets/app.css, helemaal onderaan.

      assets/app.css · prullenbak
      .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.

      index.php · PHP-databasetransactie
      // 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;
          }
      }
      2
      HTML · laat klant, product en aantal kiezenAlle inputnamen komen letterlijk terug in $_POST in het PHP-blok.

      Plak in: index.php, in het zichtbare HTML-deel waar je een bestelling wilt invoeren.

      index.php · HTML-bestelformulier
      <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>
      3
      CSS · maak van het formulier een duidelijke werkkaartorder-card en order-form komen uit de HTML hierboven.

      Plak in: jouw gekoppelde CSS-bestand, bijvoorbeeld assets/app.css.

      assets/app.css · bestelformulier
      .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.

      index.php · zoeken en filteren
      // 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();
      2
      HTML · filters en resultatenDe GET-namen search en status zijn gelijk aan PHP.

      Plak in: index.php, boven het studentenoverzicht.

      index.php · HTML-filterbalk
      <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>
      3
      CSS · compacte filterbalkDe hoofdclass is list-tools.

      Plak in: assets/app.css, onderaan.

      assets/app.css · filterbalk
      /* 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.

      index.php · PHP-paginering
      // 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();
      2
      HTML · paginanummers onder de tabelDe URL-parameter p wordt door PHP begrensd.

      Plak in: direct onder de studententabel.

      index.php · HTML-paginering
      <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>
      3
      CSS · leesbare paginanummersDe hoofdclass pagination-bar staat in de HTML.

      Plak in: assets/app.css, onderaan.

      assets/app.css · paginering
      /* 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.

      index.php · PHP-bulkacties
      // 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');
      }
      2
      HTML · selectievakjes en actiebalkselected_ids[] stuurt een lijst naar PHP.

      Plak in: als alternatief overzicht in index.php; plaats geen andere formulieren binnen dit formulier.

      index.php · HTML-bulkoverzicht
      <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>
      3
      CSS · vaste actiebalk en selecteerbare rijenDe hoofdclass is bulk-tools.

      Plak in: assets/app.css, onderaan.

      assets/app.css · bulkacties
      /* 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.

      schema.sql · activiteitenlog
      -- 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-schema.sql · MySQL-activiteitenlog
      -- 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;
      functions.php + index.php · logboek
      // 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();
      2
      HTML · leesbare tijdlijnDe tijdlijn gebruikt de velden uit $auditRows.

      Plak in: een adminpagina of onderaan index.php.

      index.php · HTML-activiteitenlog
      <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>
      3
      CSS · compacte wijzigingstijdlijnDe hoofdclass audit-log staat in de HTML.

      Plak in: assets/app.css, onderaan.

      assets/app.css · activiteitenlog
      /* 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?

      Velden of relaties1. schema.sql2. inputfunctie3. INSERT/UPDATE4. formulier en overzicht
      Nieuwe functie1. backend2. knop/formulier3. succes en fout testen
      Hoofdstuk 09

      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.

      01

      Zoeken

      Zoek tegelijk op studentnummer, naam, e-mail en opleiding met LIKE.

      PHP + JS
      02

      Filteren

      Toon alleen actieve studenten of alleen studenten uit één land.

      PHP + JS
      03

      Sorteren

      Een vaste allowlist voorkomt dat ongecontroleerde tekst in ORDER BY komt.

      PHP
      04

      Paginering

      LIMIT en OFFSET houden grote overzichten snel en leesbaar.

      PHP
      05

      Relaties

      Een foreign key verbindt studenten aan landen en voorkomt kapotte verwijzingen.

      PHP + JS
      06

      Dashboard

      Kleine COUNT(*)-queries geven direct aantallen voor het overzicht.

      PHP + JS
      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');
      Hoofdstuk 10

      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.

      OnderdeelEnkelvoudMeervoud / tabelBelangrijkste velden
      Jouw resourcestudentstudentenstudentnummer, naam, e-mail
      PHP actiecreate_studentupdate_studentdelete_…
      Databaseidstudentencreated_at, updated_at

      De veilige hernoemvolgorde

      1. 1

        Database eerst. Wijzig tabelnaam, kolommen en eventuele foreign keys in schema.sql.

      2. 2

        PHP-verwerking. Pas de inputfunctie, INSERT, SELECT, UPDATE en DELETE aan.

      3. 3

        Formulier. Laat iedere name="..." exact overeenkomen met wat PHP leest.

      4. 4

        Overzicht. Vervang tabelkoppen en de velden in de foreach-lus.

      5. 5

        Reset en test. Verwijder tijdens ontwikkeling app.sqlite zodat je nieuwe schema schoon start.

      Niet blind zoeken/vervangen

      student zit ook in woorden als studentnummer. Werk per bestand en controleer na iedere stap.

      Foutzoeker

      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.

      Typ een onderwerp of foutmelding. Je kunt ook zoeken op “XAMPP”, “opslaan” of “database”.

      Code gekopieerd