PHP/Tutorials/ToDo-Liste mit PHP und SQL

Aus SELFHTML-Wiki
< PHP‎ | Tutorials
Wechseln zu: Navigation, Suche

In diesem Tutorial entwickeln wir mit dir eine simple ToDo-Liste mit PHP und SQL. Die Anwendung erlaubt es neue ToDos auf die Liste zu schreiben und existierende ToDos abzuhaken, zu bearbeiten und zu löschen. Du solltest schon etwas Erfahrung mit PHP, SQL und objektorientierter Programmierung mitbringen. Du lernst zwei Entwurfsmuster kennen, die sich großer Beliebheit erfreuen und in vielen PHP-Frameworks Anwendung finden. Unsere Varianten sind besonders auf Verständlichkeit ausgelegt.

Zum einen lernst du das Model-View-Controller-Pattern (MVC) kennen, das den Programmablauf in verschiedene Unteraufgaben aufteilt und so für Übersichtlichkeit sorgt. Zum anderen lernst du das Repository-Pattern kennen, das dazu dient, das sogenannte Domain Model von der Speicherschicht zu trennen.

Vorbereitung

Du brauchst für dieses Tutorial mindestens PHP Version 7.3 und eine SQL-Datenbank. Die Datenbank hat das folgende Schema:

CREATE TABLE IF NOT EXISTS `todo` (
  `id` MEDIUMINT NOT NULL AUTO_INCREMENT,
  `description` VARCHAR(255) NOT NULL,
  `state` TINYINT(1) NOT NULL ,
  PRIMARY KEY (id)
)

Vorgehensweise

Wir definieren zuerst woraus ein ToDo-Item besteht, das Ergebnis ist unser sogenanntes Domain Model. Danach entwickeln wir die Datenbank-Schicht, die wir zum Speichern und Laden von ToDos aus unserer Datenbank brauchen. Dazu gehören das sogenannte Resource Model und das Repository.

Wenn wir damit fertig sind, definieren wir den View, also die Komponente, die für die Ausgabe in HTML zuständig ist.

Danach können wir den Controller definieren. Der Controller dient dazu, die Formulardaten auszuwerten, er verwendet die Speicherschicht, um die ToDos in der Datenbank zu aktualisieren und den View um die Ausgabe vorzubereiten. Erst im letzten Schritt fügen wir alle Komponenten zu einer Anwendung zusammen, was nach dieser intensiven Vorbereitung nun ein Leichtes ist.

Um dir eine Übersicht zu verschaffen ist hier schon mal die Ordnerstruktur unserer Anwendung gezeigt, am besten legst du die Dateien jetzt schon mal an.

  • src/Domain/TodoItem.php
  • src/Persistence/TodoItemRepository.php
  • src/Persistence/TodoItemResource.php
  • src/View/TodoListView.php
  • src/Controller/TodoController.php
  • index.php

Domain Model

Ein ToDo besteht aus einer textuellen Beschreibung der Aufgabe und einem Zustand, der entweder "ausstehend" oder "erledigt" sein kann. Wir definieren dafür die Klasse TodoItem in der Datei src/Domain/TodoItem.php. Die möglichen Zustände definieren wir als Klassen-Konstanten, weil ein Name wie TodoItem::STATE_PENDING aussagekräftiger als die Zahl 0 ist. Das verbessert die Lesbarkeit unseres Quelltextes.

Beispiel
<?php
declare(strict_types=1);

namespace SelfHtml\Todo\Domain;

/**
 * Ein TodoItem repräsentiert eine Aufgabe. 
 * Zu einer Aufgabe gehört eine Beschreibung und ein Zustand,
 * der anzeigt, ob die Aufgabe bereits erledigt wurde oder nicht.
 */
final class TodoItem
{
    /**
     * @var int
     */
    private $state;

    /**
     * @var string
     */
    private $description;

    /**
     * Repräsentiert den Zustand einer noch nicht erfüllten Aufgabe.
     *
     * @var int
     */
    public const STATE_PENDING = 0;

    /**
     * Repräsentiert den Zustand einer erledigten Aufgabe.
     *
     * @var int
     */
    public const STATE_DONE = 1;

    /**
     * Erzeugt eine Aufgabe mit dem übergebenen Zustand und textueller Beschreibung.
     *
     * @param int $state Zustand der Aufgabe
     * @param string $description textuelle Beschreibung der Aufgabe
     */
    public function __construct(int $state, string $description)
    {
        $this->state = $state;
        $this->description = $description;
    }

    /**
     * Gibt den Zustand der Aufgabe zurück.
     *
     * @return int
     */
    public function getState() : int
    {
        return $this->state;
    }

    /**
     * Gibt die textuelle Beschreibung der Aufgabe zurück.
     *
     * @return string
     */
    public function getDescription() : string
    {
        return $this->description;
    }
}

Persistenz-Schicht

Die Persistenz-Schicht dient zum Speichern, Laden und Löschen von ToDos in der Datenbank. Sie besteht aus zwei Komponenten: dem sogenannten Resource Model und dem Repository. Das Resource Model kümmert sich um die Verwaltung eines einzelnen Domain Models, also ein Objekt von der Klasse TodoItem, das Repository kümmert sich um die Verwaltung aller Resourcen.

Resource Model

Das Resource Model repräsentiert einen Datensatz in der Datenbank. Es ist dafür verantwortlich, ein TodoItem aus dem Datensatz auszulesen, den Datensatz mit einem aktualisierten TodoItem zu überschreiben und den Datensatz zu löschen. Für die drei Operationen hat die folgende Klasse die drei korrespondierenden Methoden fetch, update und delete. Weil die Methoden sich sehr ähneln, erklären wir im Anschluss nur die update-Methode genauer, du solltest aber versuchen, alle Methoden zu verstehen. Wir haben zu jeder Methode PHP-Kommentare geschrieben, die dir beim Verstehen helfen sollen.

update-Methode
<?php
declare(strict_types=1);

namespace SelfHtml\Todo\Persistence;

use \PDO;
use \OutOfBoundsException;
use \SelfHtml\Todo\Domain\TodoItem;

/**
 * Eine TodoItemResource repräsentiert einen existierenden Datensatz, der ein TodoItem enthält.
 * Der Datensatz wird durch eine eindeutige ID identifiziert, in diesem Fall ist
 * das eine von SQL generierte ID. Die TodoItemResource ist dafür verantwortlich, den Datensatz
 * auszulesen, ihn zu überschreiben und zu löschen.
 */
final class TodoItemResource
{
    /**
     * @var PDO
     */
    private $pdo;

    /**
     * @var int
     */
    private $id;

    /**
     * Erzeugt eine Resource mit der angegebenen Datenbank-Verbindung und Datensatz-ID.
     * Der Konstruktur erstellt keinen neuen Datensatz in der Datenbank, er muss
     * stattdessen mit der ID eines bereits existierenden Datensatzes aufgerufen werden.
     * Für die Erstellung neuer Datensätze ist das Repository verantwortlich.
     *
     * @param PDO $pdo Datenbank-Verbindung
     * @param int $id Datensatz-ID
     */
    public function __construct(PDO $pdo, int $id)
    {
        $this->pdo = $pdo;
        $this->id = $id;
    }

    /**
     * Gibt die Datensatz-ID der Ressource zurück.
     *
     * @return int
     */
    public function getId() : int
    {
        return $this->id;
    }

    /**
     * Liest das TodoItem aus dem Datensatz aus.
     *
     * @throws OutOfBoundsException falls der zu lesende Datensatz nicht existiert.
     *
     * @return TodoItem
     */
    public function fetch() : TodoItem
    {
        static $query = 'SELECT `state`, `description` FROM `todo` WHERE `id` = :id';
        $statement = $this->pdo->prepare($query);
        $result = $statement->execute([':id' => $this->id]);
        if ($statement->rowCount() === 0) {
            throw new OutOfBoundsException();
        }
        $row = $statement->fetch(PDO::FETCH_ASSOC);
        $todoItem = new TodoItem((int) $row['state'], $row['description']);
        return $todoItem;
    }

    /**
     * Überschreibt den Datensatz mit einem neuen TodoItem.
     *
     * @throws OutOfBoundsException falls der zu aktualisierende Datensatz nicht existiert.
     *
     * @param TodoItem neuer Datensatz
     */
    public function update(TodoItem $todoItem) : void
    {
        static $query = <<<SQL
            UPDATE `todo`
            SET `state` = :state, `description` = :description
            WHERE `id` = :id
            SQL;
        $statement = $this->pdo->prepare($query);
        $statement->execute([
            ':id' => $this->id,
            ':state' => $todoItem->getState(),
            ':description' => $todoItem->getDescription()
        ]);
        if ($statement->rowCount() === 0) {
            throw new OutOfBoundsException();
        }
    }

    /**
     * Löscht den Datensatz.
     *
     * @throws OutOufBoundsException falls der zu löschende Datensatz nicht existiert.
     */
    public function delete() : void
    {
        static $query = <<<SQL
            DELETE FROM `todo`
            WHERE `id` = :id
            SQL;
        $statement = $this->pdo->prepare($query);
        $statement->execute([
            ':id' => $this->id
        ]);
        if ($statement->rowCount() === 0) {
            throw new OutOfBoundsException();
        }
    }
}

Die update-Methode bekommt ein TodoItem als Parameter übergeben, sie soll den entsprechenden Datensatz damit überschreiben. Zurückgeben soll die Methode nichts. In der Variablen $query speichern wir das Grundgerüst der SQL-Abfrage, die wir für die Änderung in der Datenbank brauchen. Die SQL-Vorlage enthält die Platzhalter :state und :description, die stellvertretend für den Zustand und die Beschreibung eines ToDos stehen und einen dritten Platzhalter :id für die Datensatz-ID. In der darauf folgenden Zeile wird aus dem Grundgerüst ein Datenbank-Statement erzeugt. Erst im dritten Schritt werden mit execute die tatsächlichen Werte für die Platzhalter eingesetzt und der Schreibvorgang durchgeführt. Dieser Schritt kann schief gehen, wenn der Datensatz in der Zwischenzeit gelöscht wurde. Wir fragen deshalb mit rowCount ab, wieviele Datensätze von der Update-Anfrage betroffen waren. Sollte kein Datensatz betroffen sein, melden wir eine OutOfBoundsException.

Repository

Das Repository repräsentiert eine Datensatz-Sammlung, es kapselt den Zugriff auf die Resourcen. Mit dem Repository können wir neue Datensätze erzeugen, eine spezifische Resource auslesen oder alle Resourcen nacheinander auslesen. Dafür hat unsere Repository-Klasse die drei Methoden get, all und add. Wir erklären im Anschluss die beiden Methoden all und add. Die get-Methode ist etwas leichter zu verstehen und wird deshalb nicht erklärt.

TodoItemRepository
<?php
declare(strict_types=1);

namespace SelfHtml\Todo\Persistence;

use \PDO;
use \Iterator;
use \SelfHtml\Todo\Domain\TodoItem;

/**
 * Ein TodoItemRepository verwaltet den Zugriff auf TodoItemResources.
 */
final class TodoItemRepository
{
    /**
     * @var PDO
     */
    private $pdo;

    /**
     * Erzeugt ein Repository mit der angegebenen Datenbank-Verbindung
     *
     * @param PDO $pdo Datenbank-Verbindung
     */
    public function __construct(PDO $pdo)
    {
        $this->pdo = $pdo;
    }

    /**
     * Gibt eine Resource zurück, die den Datensatz mit der angegebenen Id repräsentiert.
     *
     * @param int $id Datensatz-ID
     * @return TodoItemResource
     */
    public function get(int $id) : TodoItemResource
    {
        return new TodoItemResource($this->pdo, $id);
    }

    /**
     * Gibt eine Liste aller Resourcen zurück.
     *
     * @return Iterator
     */
    public function all() : Iterator
    {
        static $query = 'SELECT `id` FROM `todo`';
        $statement = $this->pdo->prepare($query);
        $result = $statement->execute();
        foreach ($statement as $row) {
            yield new TodoItemResource($this->pdo, (int) $row['id']);
        }
    }

    /**
     * Erzeugt einen neuen Datensatz, der das übergebene Todo-Item repräsentiert.
     * Gibt eine Resource zurück, die den neuen Datensatz repräsentiert.
     *
     * @param TodoItem $todoItem Initialer Inhalt des neuen Datensatzes
     * @return TodoItemResource Resource, die den neuen Datensatz repräsentiert.
     */
    public function add(TodoItem $todoItem) : TodoItemResource
    {
        static $query = <<<SQL
            INSERT INTO `todo`
            SET `state` = :state, `description` = :description
            SQL;
        $statement = $this->pdo->prepare($query);
        $result = $statement->execute([
            ':state' => $todoItem->getState(),
            ':description' => $todoItem->getDescription()
        ]);
        return new TodoItemResource($this->pdo, (int) $this->pdo->lastInsertId());
    }
}

Die all-Methode liest zunächst alle Datensatz-IDs aus der Datenbank aus. Dann iterieren wir in einer foreach-Schleife über alle Ergebnis-Zeilen der SQL-Anfrage. In jedem Schleifendurchlauf erstellen wir eine TodoItemResource-Instanz, die den Datensatz mit der entsprechenden ID repräsentiert. Wir möchten eine Liste dieser Instanzen zurückgeben, das machen wir, indem wir das Keyword yield vor jeder Insanz notieren. Das Ergebnis der Methode ist dann ein Iterator, über den man mit einer foreach-Schleife iterieren kann.

Die add-Methode funktioniert ähnlich wie die update-Methode der TodoItemResource-Klasse. Im Unterschied dazu gibt add aber eine TodoItemResource zurück, die den neu angelegten Datensatz repräsentiert. Dafür brauchen wir die lastInsertId-Methode von unserer Datenbank-Verbindung.

Darstellungs-Schicht

Als nächstes kümmern wir uns um die Ausgabe in HTML. Wir werden nur ein einziges Formular brauchen, das gleichzeitig dem Erstellen, Bearbeiten und Löschen von Einträgen unserer ToDo-Liste dient. Für das Bearbeiten und Löschen benutzen wir eine private Hilfsmethode mit dem Namen renderResource, die wir in einer Schleife benutzen, um die Formularfelder für die existierenden Todos zu erzeugen.

Darstellungsschicht
<?php
declare(strict_types=1);

namespace SelfHtml\Todo\View;

use \Iterator;
use SelfHtml\Todo\Domain\TodoItem;
use SelfHtml\Todo\Persistence\TodoItemResource;

/**
 * Repräsentiert die Darstellung einer Todo-Liste.
 */
final class TodoListView
{
    /**
     * Erzeugt ein HTML-Formular zum Erstellen, Bearbeiten und Löschen von Todos.
     *
     * @param Iterator $rsources Bereits vorhandene Todos
     * @return string HTML-String
     */
    public function render(Iterator $resources) : string
    {
        $editsHtml = "";
        foreach ($resources as $resource) {
            $editsHtml .= $this->renderResource($resource);
        }
        return <<<HTML
            <!DOCTYPE html>
            <html lang="de">
                <head>
                    <title>SelfHtml TodoApp</title>
                </head>
                <body>
                    <h1>Todo Liste</h1>
                    <form method="POST">
                        $editsHtml
                        <fieldset>
                            <legend>Neuer Eintrag</legend>
                            <label>
                                <span>Beschreibung</span>
                                <input name="create[description]" maxlength="255">
                            </label>
                        </fieldset>
                        <button>Speichern</button>
                    </form>
                </body>
            </html>
            HTML;
    }

    /**
     * Erzeugt ein Teilformular zum Bearbeiten und Löschen eines existierendes Todos.
     *
     * @param TodoItemResource
     * @return string
     */
    private function renderResource(TodoItemResource $resource) : string
    {
        try {
            $item = $resource->fetch();
        } catch (OutOfBoundsException $e) {
            return '';
        }
        $state = $item->getState();
        $checked = ($state === TodoItem::STATE_DONE) ? 'checked' : '';
        $id = \htmlspecialchars((string) $resource->getId());
        $description = \htmlspecialchars($item->getDescription());
        return <<<HTML
            <fieldset>
                <legend>Eintrag bearbeiten</legend>
                <label>
                    <span>Beschreibung</span>
                    <input name="update[$id][description]" value="$description"  maxlength="255">
                </label>
                <label>
                    <span>Erledigt</span>
                    <input name="update[$id][state]" type="checkbox" $checked>
                </label>
                <label>
                    <span>Löschen</span>
                    <input name="delete[$id]" type="checkbox">
                </label>
            </fieldset>
            HTML;
    }
}

Wir müssen daran denken, dass die fetch-Methode fehlschlagen kann, wenn ein Datensatz nicht mehr existiert. In der Hilfsmethode benutzen wir deshalb einen try/catch-Block, um den Fehler abzufangen. Wenn das passiert, dann verlassen wir die Hilfsfunktion vorzeitig und geben schlicht den leeren String zurück.

Controller

Endlich haben wir alle Komponenten, die wir brauchen, um uns dem Controller zuzuwenden. Der Controller soll die Formulardaten auswerten. Er untersucht sie darauf, ob ein neues ToDo eingegeben wurde und ob bestehende ToDos bearbeitet oder gelöscht wurden. Für jede dieser Aktionen ruft der Controller dann die Persistenz-Schicht auf, um die Änderungen in der Datenbank wirksam zu machen. Im letzten Schritt benutzt er den View, um die Ausgabe vorzubereiten.

Für die einzelnen Unteraufgaben hat unser Controller vier private Hilfsmethoden: create, update, delete und showForm. Die einzige öffentliche Methode indexAction führt die Gesamtaufgabe aus, indem sie die einzelnen Hilfsmethoden aufruft. Wir erklären im Folgenden nur die update-Methode.

update-Methode
<?php
declare(strict_types=1);

namespace SelfHtml\Todo\Controller;

use \OutOfBoundsException;
use SelfHtml\Todo\Domain\TodoItem;
use SelfHtml\Todo\Persistence\TodoItemRepository;
use SelfHtml\Todo\View\TodoListView;

/**
 * Der TodoController ist verantwortlich dafür, Formulardaten auszuwerten, entsprechende
 * Änderungen in der Persitenz-Schicht auszulösen und das Bearbeitungs-Formular zu erzeugen.
 */
final class TodoController
{
    /**
     * @var TodoItemRepository
     */
    private $repository;

    /**
     * @var TodoListView
     */
    private $view;

    public function __construct(TodoItemRepository $repository, TodoListView $view)
    {
        $this->repository = $repository;
        $this->view = $view;
    }

    /**
     * Wertet die Formulardaten aus, benutzt die Persistenz-Schicht zum Speichern der Änderungen
     * und die Darstellungsschicht, um das Formular zu erzeugen.
     *
     * @param array $formData Die Formulardaten aus dem $_POST-Array
     * @return string das HTML-Formular zum Bearbeiten der Todo-Liste
     */
    public function indexAction(array $formData) : string
    {
        $this->create($formData['create'] ?? []);
        $this->update($formData['update'] ?? []);
        $this->delete($formData['delete'] ?? []);
        return $this->showForm();
    }

    /**
     * Erstellt einen neuen Todo-Datensatz.
     *
     * @param array $newTodo Beschreibung eines Datensatzes
     */
    private function create(array $newTodo) : void
    {
        if (isset($newTodo['description']) && $newTodo['description'] !== '') {
            $item = new TodoItem(TodoItem::STATE_PENDING, $newTodo['description']);
            $this->repository->add($item);
        }
    }

    /**
     * Überschreibt Datensätze.
     *
     * @param array $updates
     */
    private function update(array $updates) : void
    {
        foreach ($updates as $id => $update) {
            $state = isset($update['state']) ? (TodoItem::STATE_DONE) : (TodoItem::STATE_PENDING);
            $item = new TodoItem($state, $update['description']);
            $resource = $this->repository->get((int) $id);
            try {
                $resource->update($item);
            } catch (OutOfBoundsException $e) {
            }
        }
    }

    /**
     * Löscht Datensätze.
     *
     * @param array $deletions
     */
    private function delete(array $deletions) : void
    {
        foreach ($deletions as $id => $deletion) {
            $resource = $this->repository->get((int) $id);
            try {
                $resource->delete();
            } catch (OutOfBoundsException $e) {
            }
        }
    }

    /**
     * Gibt das HTML-Formular zurück, das zum Neuanlegen, Bearbeiten und Löschen der Todo-Items dient.
     * 
     * @return string
     */
    private function showForm() : string
    {
        $resources = $this->repository->all();
        return $this->view->render($resources);
    }
}

Die update-Methode bekommt in dem Parameter $updates alle Formular-Felder als Array übergeben, deren Name mit update beginnt. Die Struktur des Arrays ergibt sich aus unserem HTML-Formular. Das Array könnte zum Beispiel so aussehen:

[
    '3' => ['description' => 'Frühstücken', 'state' => 1],
    '5' => ['description' => 'Mittag essen', 'state' => 1]
]

Wobei die Schlüssel auf oberster Array-Ebene den Datensatz-IDs entsprechen. Wir iterieren deshalb in einer foreach-Schleife über alle Einträge der Liste. In der Schleife erzeugen wir uns jeweils ein Objekt von der Klasse TodoItem, das die geänderte Aufgabe repräsentiert. Im Anschluss holen wir uns die Ressource, die zu der Datensatz-ID gehört, und rufen die update-Methode auf der Ressource auf. Bei diesem Schritt müssen wir uns wieder daran erinnern, dass das Bearbeiten schief gehen kann, falls der Datensatz nicht mehr existiert. In diesem Fall soll unsere Methode den Fehler abfangen und ignorieren.

Anwendung verkabeln

Wir haben nun alle notwendigen Komponenten entwickelt, die wir brauchen, um unsere finale Anwendung daraus zu konstruieren, die wir in der index.php-Datei speichern. Zuerst binden wir unsere Submodule ein, dann stellen wir eine Datenbank-Verbindung her. Du musst natürlich die Datenbank-Zugangsdaten durch deine eigenen ersetzen und die Datenbank-Tabelle wie ganz oben beschrieben vorher erstellt haben. Wenn das geschehen ist, initialiseren wir die Persistenz-Schicht, indem wir das TodoItemRepository erstellen. Das selbe machen wir für den TodoListView und den TodoController. Dabei müssen wir die entsprechenden Parameter an die Konstruktoren übergeben. Der TodoController bekommt zum Beispiel sowohl das TodoItemRepository als auch den TodoListView übergeben. Dieser Vorgang nennt sich Dependency-Injection. Wenn alles verkabelt ist, rufen wir die indexAction-Methode unseres Controllers auf, und übergeben ihm das $_POST-Array, das die Formulardaten enthält. Dann müssen wir das Formular nur noch mit echo ausgeben.

das fertige Programm
<?php
declare(strict_types=1);

use \SelfHtml\Todo\Persistence\TodoItemRepository;
use \SelfHtml\Todo\View\TodoListView;
use \SelfHtml\Todo\Controller\TodoController;

require_once('src/Domain/TodoItem.php');
require_once('src/Persistence/TodoItemResource.php');
require_once('src/Persistence/TodoItemRepository.php');
require_once('src/View/TodoListView.php');
require_once('src/Controller/TodoController.php');

// Konfiguration auslesen
$dsn = 'mysql:host=localhost;dbname=todo';
$username = 'username';
$password = 'password';

// Persistenz-Schicht initialisieren
$pdo = new PDO($dsn, $username, $password);
$repository = new TodoItemRepository($pdo);

// View initialisieren
$view = new TodoListView();

// Controller initialisieren
$controller = new TodoController($repository, $view);

// Controller ausführen und den Rückgabewert ausgeben
echo $controller->indexAction($_POST);

Et voilà, fertig ist unsere ToDo-Liste.