Schritt für Schritt


Projekt einrichten

Um anzufangen, muss das Projekt eingerichtet werden. Dazu auf DOS-Ebene in das Verzeichnis htdocs gehen und

composer create-project symfony/skeleton zielpfad

eingeben. Zielpfad entspricht dabei dem Verzeichnis, in dem das Projekt installiert werden soll.

Im Verzeichnis zielpfad\public muss eine .htaccess erstellt werden. Die bekommt folgenden Inhalt:

<IfModule mod_rewrite.c>
    RewriteEngine On

    # Determine the _RewriteBase automatically and set is 
    # as environment variable
    RewriteCond %{REQUEST_URI}::$1 ^(/.+)/(.*)::\2$
    RewriteRule ^(.*) - [E=BASE:%1]

    # If the requested filename existes, simply serve it.
    # We only want to let Apache serve files and not directories.
    RewriteCond %{REQUEST_FILENAME} -f
    RewriteRule .? - [L]

    # Rewrite all other queries to the front controller.
    RewriteRule .? %{ENV:BASE}/index.php [L]
</IfModule>

Symfony selbst schlägt einen anderen Inhalt vor:

# Use the front controller as index file. It serves as a fallback solution when
# every other rewrite/redirect fails (e.g. in an aliased environment without
# mod_rewrite). Additionally, this reduces the matching process for the
# start page (path "/") because otherwise Apache will apply the rewriting rules
# to each configured DirectoryIndex file (e.g. index.php, index.html, index.pl).
DirectoryIndex index.php

# By default, Apache does not evaluate symbolic links if you did not enable this
# feature in your server configuration. Uncomment the following line if you
# install assets as symlinks or if you experience problems related to symlinks
# when compiling LESS/Sass/CoffeScript assets.
# Options +FollowSymlinks

# Disabling MultiViews prevents unwanted negotiation, e.g. "/index" should not resolve
# to the front controller "/index.php" but be rewritten to "/index.php/index".
<IfModule mod_negotiation.c>
    Options -MultiViews
</IfModule>

<IfModule mod_rewrite.c>
    # This Option needs to be enabled for RewriteRule, otherwise it will show an error like
    # 'Options FollowSymLinks or SymLinksIfOwnerMatch is off which implies that RewriteRule directive is forbidden'
    Options +FollowSymlinks

    RewriteEngine On

    # Determine the RewriteBase automatically and set it as environment variable.
    # If you are using Apache aliases to do mass virtual hosting or installed the
    # project in a subdirectory, the base path will be prepended to allow proper
    # resolution of the index.php file and to redirect to the correct URI. It will
    # work in environments without path prefix as well, providing a safe, one-size
    # fits all solution. But as you do not need it in this case, you can comment
    # the following 2 lines to eliminate the overhead.
    RewriteCond %{REQUEST_URI}::$0 ^(/.+)/(.*)::\2$
    RewriteRule .* - [E=BASE:%1]

    # Sets the HTTP_AUTHORIZATION header removed by Apache
    RewriteCond %{HTTP:Authorization} .+
    RewriteRule ^ - [E=HTTP_AUTHORIZATION:%0]

    # Redirect to URI without front controller to prevent duplicate content
    # (with and without `/index.php`). Only do this redirect on the initial
    # rewrite by Apache and not on subsequent cycles. Otherwise we would get an
    # endless redirect loop (request -> rewrite to front controller ->
    # redirect -> request -> ...).
    # So in case you get a "too many redirects" error or you always get redirected
    # to the start page because your Apache does not expose the REDIRECT_STATUS
    # environment variable, you have 2 choices:
    # - disable this feature by commenting the following 2 lines or
    # - use Apache >= 2.3.9 and replace all L flags by END flags and remove the
    #   following RewriteCond (best solution)
    RewriteCond %{ENV:REDIRECT_STATUS} =""
    RewriteRule ^index\.php(?:/(.*)|$) %{ENV:BASE}/$1 [R=301,L]

    # If the requested filename exists, simply serve it.
    # We only want to let Apache serve files and not directories.
    # Rewrite all other queries to the front controller.
    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteRule ^ %{ENV:BASE}/index.php [L]
</IfModule>

<IfModule !mod_rewrite.c>
    <IfModule mod_alias.c>
        # When mod_rewrite is not available, we instruct a temporary redirect of
        # the start page to the front controller explicitly so that the website
        # and the generated links can still be used.
        RedirectMatch 307 ^/$ /index.php/
        # RedirectTemp cannot be used instead
    </IfModule>
</IfModule>

Subversion

Damit die Versionierung klappt, muss Subversion eingerichtet werden. Zuerst einmal unter den Einstellungen unter Subversion die Option Enable Interactive Mode ausschalten. In Subversion auf dem Server das gewünschte Verzeichnis erstellen.

Jetzt im Dateiexplorer über das Kontextmenü die nicht erforderlichen Verzeichnisse von Subversion ausnehmen: .idea, var, vendor, public/bundles, /config/secrets/prod/prod.decrypt.private.php, .env.local, .env.local.php, .env.*.local.

Dann in PHPStorm unter VCS / Import into Version Control aufrufen und in das zuvor erstellte Repository exportieren. Anschließend gleich wieder mit VCS / Get from Version Control importieren, damit Änderungen erkannt werden. Schließlich über VCS noch die Option Enable Version Control Integration auswählen und die passenden Einstellungen wählen.

Git

Im Menü von PHPStorm unter VCS / Share Project on GitHub auswählen. Anschließend kommt die Meldung, dass eine dubiose Eigentümergeschichte für das Verzeichnis festgestellt worden sei. Um das zu beheben, auf XAMPP im Projektverzeichnis den Befehl git config --global --add safe.directory '%(prefix)///XAMPP/htdocs/messagetest' ausführen. messagetest muss durch das entsprechende Verzeichnis ersetzt werden.

Zugriff auf die neue Site

Für den Zugriff auf die Site sind zwei Anpassungen erforderlich:

In der xampp\apache\conf\extra\httpd-vhosts.conf müssen die folgenden Zeilen eingefügt werden, wobei die Parameter hinter DocumentRoot und ServerName natürlich angepasst werden müssen.

<virtualhost *:80="">
    DocumentRoot "C:\xampp\htdocs\symfony\public"
    ServerName symphony.test
</virtualhost>

Anschließend muss XAMPP neu gestartet werden.

Die zweite Änderung muss auf denjenigen PCs innerhalb des lokalen Netzes durchgeführt werden, auf denen die Seite angezeigt werden soll. Und zwar in der Datei C:\Windows\System32\drivers\etc\hosts. Dort muss für die neue Site eine neue Zeile eingefügt werden: 192.168.0.12 adlibitum.test. Die IP ist dabei die IP des XAMPP-Servers und der zweite Parameter derjenige, der in der httpd-vhosts.conf als ServerName eingetragen worden ist.

In PHPStorm in den Einstellungen nach Symfony suchen und das Plugin für dieses Projekt aktivieren. Anschließend noch nach Composer suchen und prüfen, ob der Pfad zu composer.json unter Path to composer.json korrekt eingetragen ist.

Maker-Bundle

Mit Hilfe des Maker-Bundles lassen sich zahlreiche Arbeiten in Synfony erheblich abkürzen – erstellen von Kontrollern, Klassen Tests und mehr. Zur Installation:

composer require maker --dev

Um Unit-Tests zu erzeugen:

php bin/console make:test TestCase SpamCheckerTest

Für funktionale Tests:

php bin/console make:test WebTestCase ConferenceControllerTest

 

Doctrine

Mit Hilfe von Doctrine wird die Verwaltung von Datenbanken deutlich vereinfacht.

composer require doctrine

Konfiguriert wird in der .env:

DATABASE_URL=mysql://db_user:db_password@127.0.0.1:3306/db_name?serverVersion=mariadb-10.4.14

Die mariadb – auch auf die Version achten – ist wichtig, sonst kommt der Fehler sync-metadata-storage soll gestartet werden. Unter Umständen sind auch in der config\packages\doctrine.yaml Einstellungen erforderlich.

Darauf achten, dass wenn im Passwort Sonderzeichen enthalten sind, muss dieses urlencoded werden, sonst wird es nicht korrekt erkannt.

Anschließend kann auf DOS-Ebene die Datenbank mit php bin/console doctrine:database:create erstellt werden.

Tabellen werden mit php bin/console make:entity erstellt. Anschließend mit php bin/console make:migration eine Migration erstellen. Diese im Verzeichnis migrations prüfen, bevor sie mit php bin/console doctrine:migrations:migrate ausgeführt wird.

Um eine Tabelle zu füllen, einfach in einem Controller ein neues Objekt für die gewünschte Tabelle anlegen: $daten = new Tabellenklasse();. Anschließend mit $daten->setWert("xxx"); die Werte setzen.

Damit die Werte auch in der Datenbank gespeichert werden können, muss die Klasse zum Speichern mit einem Typehint versehen werden: public function new(EntityManagerInterface $em). Anschließend wird mit den beiden folgenden Befehlen endgültig gespeichert:

$em->persist($daten);
$em->flush;

Um Daten aus der Datenbank herauszuholen, muss auch die Klasse zum Anzeigen der Daten um das EntityManagerInterface erweitert werden. Abgeholt werden die Daten dann mit $repository = $em->getRepository(Tabellenklasse::class);. Die eigentlichen Daten kommen dann mit $variable = $repository->findAll(); oder einer der anderen Funktionen des Objekts.

Wird eine spezielle Datenabfrage benötigt, so ist diese im zugehörigen Repository zu implementieren.

Twig installieren

Wenn HTML-Templates verwendet werden sollen, wird Twig benötigt:

composer require twig

Damit das dann auch genutzt werden kann, muss der Controller den AbstractController extenden: class XxxController extends AbstractController.

Innerhalb der im Controller erstellten Routen muss dann jeweils ein return $this->render('vorlage.html.twig',['id'=>$id]) enthalten sein. Die betreffende Twigdatei muss natürlich vorhanden sein. Der zweite Parameter ist ein Array mit fakultativen Parametern. In diesem Beispiel wieder die Artikel-id von vorhin.

Die twig-Dateien sollten der Übersichtlichkeit in einem Unterverzeichnis von templates erstellt werden. Der Name des Unterverzeichnisses sollte dem Klassennamen entsprechen.

Um einen Block zu ersetzen, einfach den neuen Code zwischen {% block name %} und {% endblock %} schreiben. Soll der ursprüngliche Block ergänzt werden, dann am Anfang des Blocks {{ parent() }} eingeben.

Twig lässt sich auch um eigene Funktionen erweitern. Infos dazu hier.

Zugangskontrolle

Wenn sich User anmelden sollen, dann muss zunächst das Security-Bundle installiert werden:

composer require symfony/security-bundle

Dann muss die Klasse für die Benutzerdaten erstellt werden:

php bin/console make:user

Um die Passwörter für die Speicherung in der Datenbank zu verschlüsseln:

// src/DataFixtures/UserFixtures.php

+ use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
// ...

class UserFixtures extends Fixture
{
+     private $passwordEncoder;

+     public function __construct(UserPasswordEncoderInterface $passwordEncoder)
+     {
+         $this->passwordEncoder = $passwordEncoder;
+     }

    public function load(ObjectManager $manager)
    {
        $user = new User();
        // ...

+         $user->setPassword($this->passwordEncoder->encodePassword(
+             $user,
+             'the_new_password'
+         ));

        // ...
    }
}

Um Passwörter für Testdaten manuell zu verschlüsseln:

php bin/console security:hash-password

Sobald die User-Tabelle angelegt ist, kann das Loginformular erzeugt werden:

php bin/console make:security:custom

Und zwar ein Login form authenticator und ein logout zu erzeugen ist sinnvoll.

Im src/security/LoginFormAuthenticator.php muss in der Funktion onAuthenticationSuccess dann noch der Pfad eingestellt wird, zu dem der User weitergeleitet werden soll, wenn er sich erfolgreich angemeldet hat:

return new RedirectResponse($this->urlGenerator->generate('homepage'));

Im src/controller/SecurityController.php muss in der Funktion logout dann noch eingestellt werden, was nach dem Logout passieren soll. Sinnvoll ist eine Umleitung zum login:

return new RedirectResponse($this->generateUrl('app_login'));

Auch in der config/packages/security.yaml muss eingestellt werden, was nach dem Logout passieren soll:

logout:
path: app_logout
# where to redirect after logout
target: app_login

Übersetzungen für die Texte werden in der translations/security.de.yml eingetragen. Und zwar in der Form

'Invalid credentials.': 'Das eingegeben Passwort ist nicht korrekt!'
'Email could not be found.': 'Diese eMailadresse ist unbekannt!'

Der erste Parameter kann im Profiler unter Translation ausgelesen werden. Fehlende Übersetzungen werden dort mit deren Namen aufgeführt.

Um den Zugang zu einzelnen Modulen zu regeln, in der Annotation des jeweiligen Controllers folgendes einfügen:

* @IsGranted("ROLE_USER")

Hier einfach die gewünschte Mindestrolle eintragen und fertig. Wenn die Annotations installiert worden sind.

Diese Annotation ist auch für ganze Klassen möglich. Einfach die obige Zeile über der Klasse einfügen und sie gilt für alle Methoden in dieser Klasse.

Alternativ kann auch direkt per PHP der Zugang kontrolliert werden. Hierfür folgende Zeile einfügen:

$this->denyAccessUnlessGranted('ROLE_USER');

Die zuvor genannten Annotation entfällt dann.

OTP

Um One Time Passwords zu integrieren, composer require cmsecure/authenticator-client installieren. Infos finden sich hier. Nicht vergessen, unter config/pckages/scheb_2fa.yarn den Herausgeber zu definieren.

Als Basis für Projekte kann OTP-Starter dienen. Ist in Subversion abgelegt.

In Git gibt es hierfür OTP-Starter.

Controller erstellen

Um einen neuen Controller zu erstellen, im Verzeichnis src\controller eine entsprechende Klasse erstellen. Der Vorname der Klasse ist frei, der zweite Bestandteil muss Controller sein. Also beispielsweise ArticleController.

Innerhalb des Controllers muss ein return auftauchen: return new Response("Text"); Die Response muss aus der HttpFoundation stammen.

Einfacher geht das Erstellen eines Controllers mit dem Maker: php bin/console make:controller.

Router installieren

Dieses Modul wird in Symfony 7 nicht mehr benötigt.

Mit Hilfe des Routers kann das umständliche Anpassen der Routen in der config\routes.yaml entfallen.

composer require annotations

Statt dessen werden die Routen im Docblock der jeweiligen Funktionen eingetragen. Und zwar so:

/**
 * @Route("/", name="homepage")
 */

Der erste Parameter („/“) ist das, was man im Browser eingeben muss, um an diese Stelle zu kommen. Der name ist dafür gedacht, diese Route im Code anzusprechen.

Beim Autovervollständigen von Route darauf achten, dass Symfony\Content\Routing verwendet wird.

Um innerhalb der Routen Parameter zu verwenden, werden diese in {} gestellt. Beispielsweise @Route("artikel/{id}"). Bei der Funktionsdefinition taucht diese Variable dann wieder auf: public function show($id).

Um festzustellen, welche Routen es in einer App gibt, einfach php bin/console debug:router eingeben.

Security Checker installieren

Mit dessen Hilfe kann das Recipe-System erkundet werden.

composer require sec-checker --dev

Assets installieren

Damit in Twig-Templates andere Elemente komfortabler referenziert werden können, die Assets installieren:

composer require symfony/asset

Um zum Beispiel CSS-Dateien einzubinden, in Zukunft einfach nach <link rel="stylesheet" href=" den Namen der CSS-Datei eingeben und die von PHPStorm vorgeschlagene Datei übernehmen. Das fügt automatisch das passende asset ein.

Web Debug Toolbar

Viele interessante Informationen liefert der Profiler:

# composer require profiler --dev # ist in debug enthalten
composer require debug --dev

Dadurch erscheint nicht nur die Toolbar am unteren Bilschirmrand, man kann im Quellocde dump($variable1, $variable2); eingeben. Dadurch werden die gewünschten Variablen oben am Bildschirm angezeigt. In Twig braucht es {{ dump() }}. Damit werden alle verfügbaren Variablen angezeigt.

Die Installation von debug ist erforderlich, damit in Twig die Anzeige der Variablen übersichtlicher wird.

Logging

Um Infos in die Logdatei (var\log\dev.log) zu schreiben, kann in jeder beliebigen Funktion $logger->info("Mein Kommentar"); verwendet werden. Hierfür muss jedoch der jeweiligen Funktion ein weiteres Argument übergeben werden: public function funktionsName(LoggerInterface $logger). Dabei darauf achten, das LoggerInterface automatisch vervollständigen zu lassen, damit das Use-Statement automatisch ergänzt wird. Diese Daten können übrigens auch in der Debugtoolbar eingesehen werden. Und zwar unter Logs / Debug.

Alternativ kann auch in PHP direkt der Befehl dd("Text: " . $variable); eingebaut werden. Damit bricht die Ausführung des Codes zwar ab, aber der Inhalt der Variablen wird am Bildschirm angezeigt.

Und man kann in den Entwicklertools unter Konsole Informationen anzeigen lassen, indem man an beliebiger Stelle im Code das hier einbaut:

echo "<script>console.log('Durchlauf " . $x . "')</script>";
ob_flush();
flush();
sleep(1);

Die flush- und sleep-Zeilen sind erforderlich, um die Infos sofort auszugeben, nicht erst am Ende des Programms.

Darüber hinaus kann man im Code die Zeile error_log("Text"); verwenden. Das schreibt eine Datei, die man sich mit ddev logs -s web anzeigen lassen kann.

Autowiring

Um herauszufinden, welche Services zur Verfügung stehen, php bin/console debug:autowiring eingeben. Es erscheint eine Liste aller aktuell möglichen Dienste.

Umgebungsvariablen

In der .env können beliebige Variablen eingefügt werden. Per Konvention werden diese komplett groß geschrieben und der Wert hinter = eingefügt. Werte mit Leerzeichen müssen in Anführungszeichen geschrieben werden: VARIABLE="Das ist der Inhalt". Um diese Variablen in dem YAML-Dateien zu verwenden, werden sie dort mit %VARIABLE% eingefügt. Um zu prüfen, ob die .env-Datei auch außerhalb der dev-Umgebung verwendet wird, in der composer.json prüfen, ob symfony/dotenv unter require steht. Wenn nicht, mit composer remove symfony/dotenv entfernen und gleich wieder mit composer require symfony/dotenv einfügen.

Damit später noch erkennbar ist, wozu die Variablen gehören, diese mit Kommentaren „umklammern“:

### CUSTOM VARS 
VAR1=Hallo
### END CUSTOM VARS

Dummy-Daten

Wenn die Tabellen einer Datenbank mit Dummydaten befüllt werden sollen, dann die Fixtures installieren:

composer require orm-fixtures:3.0.2 --dev

Um dann Daten zu erzeugen, php bin/console make:fixtures aufrufen. Als Namen dann den Tabellennamen + Fixtures angeben, zum Beispiel ArticlesFixtures was dann eine neue Datei im Verzeichnis src/DataFixtures zur Folge hat. Diese muss aber erst noch mit Code gefüllt werden, der die gewünschten Datensätze erzeugt.

Wesentliche Bestandteile dabei sind $article = new Articles();, $manager->persist($article); sowie $manager->flush();. Am Anfang nicht vergessen, die Funktion load zu ergänzen: public function load(ObjectManager $manager).

Um diese Daten dann zu erzeugen, einfach php bin/console doctrine:fixtures:load aufrufen. Zum Schluss dann die Fixtures noch laden: php bin/console doctrine:fixtures:load. Details hierzu an dieser Stelle.

Erweiterung zu Doctrine

Werden für die Datenbank Funktionen wie Logging, Blaming oder Zeit­stempel benötigt, dann sollte das Extensionbundle installiert werden:

composer require stof/doctrine-extensions-bundle

Wenn dessen Installation mit der Meldung fehlschlägt, dass kein installierbares Paket gefunden werden konnte, dann folgendes machen:

composer require doctrine/common:"^2.11" doctrine/persistence:"^1.3"

Das Problem rührt daher, dass das Extensions-Bundle nicht mit der aktuellen Version von Doctrine kompatibel ist (Stand 09/2020). Dann nochmal das Bundle installieren und es klappt.

Sobald das Bundle installiert ist, gibt es unter config/packages/stof_doctrine_extensions.yaml eine Konfigurationsdatei, in der die gewünschten Erweiterungen aktiviert werden können.

Formulare

Werden Formulare benötigt, muss

composer require form

installiert werden. Das Erzeugen von Code kann wieder automatisch erfolgen: php bin/console make:form. Klassennamen in der Form ArticleFormType angeben. Die fertige Klasse sieht dann in etwa so aus:

class ArticleFormType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('name')
            ->add('warehouse')
            ->add('location')
            ->add('onstock')
            ->add('primecost')
            ->add('retail')
        ;
    }
}

Es wird auch eine Route benötigt, mit der man das Formular aufrufen kann:

/**
* @Route("admin/article/new", name="admin_article_new")
*/
public function new(EntityManagerInterface $em)
{
$form = $this->createForm(ArticleFormType::class);

return $this->render('article_admin/new.html.twig' , [
'articleForm' => $form->createView()
]);
}

Und natürlich auch das hier definierte Twig-Template. Dieses lässt sich automatisch erzeugen, indem man den Cursor auf den Templatenamen setzt und Alt+Enter drückt. Dann auswählen, dass das Template erstellt werden soll und in dem Template beispielsweise folgendes einfügen:

{% extends 'base.html.twig' %}

{% block body %}
<h1>Neuen Artikel anlegen</h1>

{{ form_start(articleForm) }}
{{ form_widget(articleForm) }}

<button type="submit" class="btn btn-primary">speichern</button>
{{ form_end(articleForm) }}
{% endblock %}

Damit ist das Formular fertig. Allerdings fehlt noch die Verarbeitung der Benutzereingaben.

Diese erfolgt in der Regel in der Klasse, die auch das Formular erzeugt, also in unserem Beispiel in new. hier wird folgendes geprüft:

$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid())

Innerhalb der if-Schleife erfolgt dann die Verarbeitung der vom Benutzer eingegebenen Daten. Wird die Seite neu aufgerufen, wird die if-Schleife übersprungen und gleich das Formular erzeugt.

Innerhalb der if-Schleife werden dann die Werte zugewiesen, in die Datenbank geschrieben

$data = $form->getData();
$article = new Articles();
$article->setName($data['name']); (*)
$article->setOnstock($data['onstock']);
$article->setLocation($data['location']);
$article->setPrimecost($data['primecost']);
$article->setRetail($data['retail']);

$em->persist($article);
$em->flush();

$this->addFlash('success', 'Artikel wurde angelegt!');

return $this->redirectToRoute('articles');

und der User auf die gewünschte Zielseite geleitet, hier auf articles. Damit der Anwender auch sieht, dass es geklappt hat, wird die Flash-Meldung erzeugt.

Damit er diese Nachricht auch sieht – sie kann nur ein mal abgerufen werden –, muss in Twig

{% for message in app.flashes('success') %}
<div class="alert alert-success">
{{ message }}
</div>
{% endfor %}

eingebaut werden. Die for-Schleife wird benötigt, weil auch mehrere Nachrichten hinterlegt sein könnten.

Der mit dem Stern (*) markierte Bereich kann unter Umständen recht umfangreich sein und kann gespart werden, wenn im FormType mit Strg+O folgende Methode eingefügt wird

public function configureOptions(OptionsResolver $resolver)
{
    $resolver->setDefaults([
        'data_class' => Articles::class
    ]);
}

Damit werden nicht nur die Formularfelder entsprechend ihrer Datentypen gerendert sondern auch die Zuordnung zu den Datenbankfeldern wird automatisiert. Damit wird der obige Code erheblich kompakter

/** @var Articles $article */
$article= $form->getData();

$em->persist($article);
$em->flush();

$this->addFlash('success', 'Artikel wurde angelegt!');

return $this->redirectToRoute('articles');

Die Inlinedokumentation ist erforderlich damit Symfony weiß, um welche Art von Variable es sich handelt.

Diese configureOptions sorgen übrigens auch dafür, dass verknüpfte Tabellenfelder in einer Dropdownliste angezeigt werden. Allerdings muss hierfür in src\entity\entsprechendeKlasse eine Funktion eingefügt werden

public function __toString()
{
    return $this->getName();
}

Hier werden normalerweise zwei Eingabefelder fürs das Passwort benötigt:
$builder
->add('password', RepeatedType::class, [
'type' => PasswordType::class,
'first_options' => ['label' => "Passwort"],
'second_options' => ['label' => "Passwort bestätigen"],
'invalid_message' => 'Die eingegebenen Passwörter stimmen nicht überein',
'required' => true,
'attr' => [
'autofocus' => true,
],
'mapped' => false
])
;

Das führt dann im Controller beim Einlesen der Parameter mit Hilfe von $form->getData() zu dem Problem, dass das Array leer ist. Zumindest in Tests. Daher ist das Passwort auch für Tests nur zuverlässig abrufbar mit: $form->get('password')->getData().

Formulare validieren

Um Formulareingaben prüfen zu können, gibt es ein Zusatzmodul: composer require validator.

Umfangreiche Listen

Um umfangreiche Listen in Einzelseiten aufzuteilen, gibt es ein hilfreiches Bundle

composer require knplabs/knp-paginator-bundle

Eigene Services

Um eigene Services zu erstellen – Funktionen für die mehrfache Verwendung auslagern – einfach ein eigenes Verzeichnis und in diesem die gewünschte Klasse erstellen. Ein Anleitung dazu hier.

Um die Klasse ansprechen zu können, diese einfach in der verwendenden Funktion als Parameter mit eintragen. Verwendet die aufgerufene Funktion ihrerseits Services, können diese im Konstruktor deklariert werden.

Cache leeren

In manchen Fällen ist es erforderlich, den Cache zu leeren, damit Symfony Änderungen erkennt. Hierfür im Verzeichnis der jeweiligen App php bin/console cache:clear [--env=prod] aufrufen. Das ist vor allem dann der Fall, wenn die APP_ENV umgestellt wird.

Mime

Falls Mimefunktionen benötigt werden, müssen diese installiert werden:

composer require symfony/mime

PDF

Falls das Programm PDFs erzeugen muss, EasyPDF verwenden: composer require chadyred/easy-pdf-bundle. Dort scheint allerdings seit 2017 nichts mehr passiert zu sein. Alternative: composer require setasign/fpdf. Da ist der letzte Eintrag immerhin von 2023.

eMails verschicken

Wenn eMailversand benötigt wird, dann composer require symfony/mailer installieren.

Anschließend in der .env den Dienstleister einrichten. Aufpassen, dass wenn Passwort, Username oder Host Sonderzeichen enthalten (+, @, $, #, /, :, *, !), das Ganze urlencoded werden muss:

MAILER_DSN=smtp://user@nowhere.org:g654hJ_R6%25b2@mail.server.net:465

Charts oder Diagramme

Werden in einer App Diagramme benötigt, muss Charts.js installiert werden:

composer require encore
yarn install
composer update symfony/flex
composer require symfony/ux-chartjs
npm install --force
yarn encore dev

In der Vorlage base.html.twig müssen jetzt CSS und JS eingebunden werden:

{% block stylesheets %}
    {{ encore_entry_link_tags('app') }}
{% endblock %}

{% block javascripts %}
    {{ encore_entry_script_tags('app') }}
{% endblock %}

und zwar im <head>-Bereich! Symfony fügt automatisch defer zum Aufruf hinzu, so dass die Seite ohne auf die Scripte zu warten geladen wird.

Um dann ein Chart zu erzeugen, erfolgt im entsprechenden Controller der Aufruf:

public function index(ChartBuilderInterface $chartBuilder): Response
{
    $chart = $chartBuilder->createChart(Chart::TYPE_LINE);
    $chart->setData([
        'labels' => ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli'],
        'datasets' => [
            [
                'label' => 'Umsätze der letzten 7 Monate',
                'backgroundColor' => 'rgb(255, 99, 132)',
                'borderColor' => 'rgb(255, 99, 132)',
                'data' => [0, 0, 0, 2, 20, 30, 45],
            ],
        ],
    ]);

    $chart->setOptions([/* ... */]);

    return $this->render('home/index.html.twig', [
        'chart' => $chart,
    ]);
}

Dabei das ChartBuilderInterface im Funktionsaufruf nicht vergessen.

Um das Chart dann anzuzeigen, in der entsprechenden Twigvorlage {{ render_chart(chart, {'class': 'mein-diagramm') }} einbauen. Der zweite Parameter ist optional und weißt dem Chart eine eigene Klasse zu.

Wird in solchen Projekten Bootstrap benötigt, dann yarn add bootstrap --dev ausführen, um es einzubinden. Dieser Schritt kann entfallen, wenn Bootstrap lokal eingebunden wird anstatt über CDN.

Fortschrittsbalken

Um bei längeren Laufzeiten eines Controllers den User darüber zu informieren, dass das Programm noch läuft und was es gerade tut, wird der Messenger benötigt. Dieser wird mit composer require messenger installiert. Da der Messenger eine Tabelle in einer Datenbank benötigt, ist Doctrine ebenfalls erforderlich. Der hier aufgeführte Code geht davon aus, dass die zugehörige Tabelle (actions) die Felder date (datetime), remark (varchar) und done (Boolean) enthält.

Damit die Datenbank befüllt werden kann, muss auch der Doctrine Messenger installiert werden: composer require symfony/doctrine-messenger.

In der .env nicht vergessen MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 von 0 auf 1 zu setzen.

Für Infonachrichten eignen sich flash()es eher nicht, weil die nach einer einmaligen Anzeige verschwinden und die anzeigende Seite immer wieder neu geladen werden muss. Dann würde der Anwender ja wieder eine Zeitlang nichts sehen. Statt dessen wird in der Session ein Array mit diesen Nachrichten abgelegt, das immer wieder um die neuste Aktion erweitert und dessen Inhalt am Bildschirm angezeigt wird.

Unter src müssen zwei Verzeichnisse erstellt werden: Message und MessageHandler. Im Ersten braucht es eine Klasse zum Ermitteln der zu übergebenden Parameter:

<?php

namespace App\Message;

class ErmittleDaten
{
    private $data;

    public function __construct($data)
    {
        $this->data = $data;
    }

    public function getData()
    {
        return $this->data;
    }
}

Im Zweiten braucht es einen Handler für die eigentliche Arbeit. Hier ist der Befehl #[AsMessageHandler] wichtig, damit die Klasse als MessageHandler erkannt wird:

<?php

namespace App\MessageHandler;

use App\Entity\Actions;
use App\Message\ErmittleDaten;
use Doctrine\ORM\EntityManagerInterface;

#[AsMessageHandler]
class ErmittleDatenHandler
{
    private const SLEEP = 1000000000;

    public function __construct(private EntityManagerInterface $em)
    {
    }


    public function __invoke(ErmittleDaten $message)
    {
        // Parameter aholen
        $data = $message->getData();

        // das Startsignal
        $daten = new Actions();
        $daten->setDate(new \DateTime());
        $daten->setRemark("start");
        $this->em->persist($daten);
        $this->em->flush();

        // vor der eigentlichen Arbeit den zugehörigen Hinweis speichern
        $daten = new Actions();
        $daten->setDate(new \DateTime());
        $daten->setRemark("Schritt 1");
        $this->em->persist($daten);
        $this->em->flush();

        for ($x=0; $x < self::SLEEP; $x++) {
            //hier erfolgt die eigentliche Arbeit;
        }
}

Im Kontroller, der die Arbeit auslöst und die Infoseite für den User anzeigt, braucht es dann in etwa diese Struktur:

#[Route('/berechnen', name: 'app_berechnen')]
public function berechnen(MessageBusInterface $messageBus, EntityManagerInterface $em, SessionInterface $session): Response
{
	$working = true;
	$fertig = false;
	$actions = "";

	$repository = $em->getRepository(Actions::class);
	$running = $repository->findBy(["done" => null, "remark" => "start"]);

	if ($running) {
		$query = "SELECT * FROM `actions` WHERE done is null AND remark != 'start'";
		$conn = $em->getConnection();
		$stmt = $conn->executeQuery($query);

		$unbearbeitet = $conn->fetchAllAssociative("SELECT * FROM `actions` WHERE done is null AND remark != 'start'");
		$actions = $session->get('actions', []);

		foreach ($unbearbeitet as $eintrag) {
			$index = count($actions);
			$actions[$index] = $eintrag["remark"];
			$session->set("actions", $actions);

			$entity = $repository->find($eintrag["id"]);
			$entity->setDone(true);
			$em->flush();
			if ($eintrag["remark"] == "fertig") {
				$entity = $repository->find($running[0]->getId());
				$entity->setDone(true);
				$em->flush();
				$working = false;
				$session->set("actions", array());
				$actions = array();
				$fertig = true;
			}
		}

		return $this->render('berechnungen/index.html.twig', [
			'controller_name' => 'Berechnen',
			'working'         => $working,
			'actions'         => $actions,
			'fertig'          => $fertig,
		]);
	} else {
		$asyncCommand = new ErmittleDaten([
			'some_data' => 'nur simple Daten sind erlaubt, die serialisiert werden können (z.B. nur eine Entity-ID und im Handler muss das Objekt über die Datenbank neu laden)',
		]);
		$messageBus->dispatch($asyncCommand);

		return $this->render('berechnungen/index.html.twig', [
			'controller_name' => 'Berechnen',
			'working'         => $working,
			'actions'         => $actions,
			'fertig'          => $fertig,
		]);
	}
}

Im Prinzip geht es darum zu prüfen, ob die Verarbeitung gestartet hat. Dazu in der Datenbank nach dem Eintrag „start“ suchen und wenn vorhanden prüfen, ob weitere Einträge vorhanden sind. Jeden neuen Eintrag auslesen, in der Datenbank als gelesen kennzeichnen und an das Array in der Session anhängen.

Dann die Parameter für die Anzeigeseite setzen. In Abhängigkeit davon, ob die Arbeit noch gar nicht angefangen hat, läuft oder schon abgeschlossen ist.

Auf der Anzeigeseite müssen dann nicht nur die Einträge aus dem Array angezeigt werden, die Seite muss auch regelmäßig neu aufgerufen werden – hierfür dient der Parameter working – , damit die neuen Einträge ebenfalls angezeigt werden können:

{% extends 'base.html.twig' %}

{% block title %}Berechnen{% endblock %}

{% block refresh %}
    {% if working %}
        <meta http-equiv="refresh" content="60">
    {% endif %}
{% endblock %}

{% block body %}
    <style>
        .example-wrapper { margin: 1em auto; max-width: 800px; width: 95%; font: 18px/1.5 sans-serif; }
        .example-wrapper code { background: #F5F5F5; padding: 2px 6px; }
    </style>

    {% for action in actions %}
        <div class="actions">
            {{ action }}
        </div>
    {% endfor %}
    {% if fertig %}
        <div class="actions">
            alles erledigt
        </div>
    {% endif %}

    <div class="example-wrapper">
        <h1>Berechnen</h1>
    </div>
{% endblock %}

Für das regelmäßige Updaten der Anzeige ist der block refresh verantwortlich. Der darf natürlich nur aufgerufen werden, wenn die Verarbeitung noch läuft. Dafür dienen die Übergabeparameter aus dem Controller.

Auf Git findet sich das alles unter https://github.com/Odido/async.git.

Damit das Ganze jetzt auch noch automatisch abläuft, muss in config/packages/messenger.yaml unter transports: das async: aktiviert und unter routing: die Klasse zum Ermitteln der Daten angegeben werden:

framework:
    messenger:
        # Uncomment this (and the failed transport below) to send failed messages to this transport for later handling.
        # failure_transport: failed
        default_bus: messenger.bus.default
        buses:
            messenger.bus.default: ~

        transports:
            # https://symfony.com/doc/current/messenger.html#transport-configuration
            async: '%env(MESSENGER_TRANSPORT_DSN)%'
            # failed: 'doctrine://default?queue_name=failed'
            # sync: 'sync://'

        routing:
            # Route your messages to the transports
            'App\Message\ErmittleDaten': async

Allerdings ist unbedingt darauf zu achten, dass damit kein Debugging möglich ist. Hierfür müssen die Einträge im messenger.yaml erst wieder deaktiviert werden.

Darüber hinaus muss, damit der Messenger die Aufträge auch ausführt, im Root der App der Befehl php bin/console messenger:consume -vv ausgeführt werden. Dieses Programm läuft permanent und prüft ständig, ob neue Aufträge für die Abarbeitung auflaufen. Sobald ein Auftrag aufläuft, wird dieser übergeben und ausgeführt. Das -vv sollt eigentlich Details zu jedem gefunden Befehl anzeigen, ist auf meinem XAMPP allerdings ohne Funktion.

Deployment

von Hand

Um ein Programm auf den Liveserver zu übertragen, müssen die Verzeichnisse assets, bin, config, migrations, public, src, translations und templates übertragen werden. Darüber hinaus die Dateien .env, composer.json, composer.lock, docker-compose.yaml, package.json, symfony.lock, webpack.config.js und yarn.lock.

Dann die .env anpassen, insbesondere die Einstellungen für die Datenbank und APP_ENV und anschließend composer install ausführen, um alle notwendigen Module zu installieren. Für die Datenbank dann zuerst die Einstellungen in der .env anpassen und anschließend

php bin/console doctrine:database:create
php bin/console doctrine:migrations:migrate

Bei christo.net muss die Datenbank von Hand erstellt werden. Außerdem ist bei jedem Provider in den Hostingeinstellungen im Dokumentenstamm das /public hinten anzufügen.

mit Git

Auf dem Liveserver in das Zielverzeichnis gehen und git clone https://github.com/username/repository.git ausführen. Dann auf dem Server die Datenbank, falls benötigt, erstellen und in der .env die Zugehörigen Daten hinterlegen und mit composer install alle bentöigten Module installieren. Schließlich noch mit php bin/console doctrine:migrations:migrate die Datenbank füllen.

Wenn es Aktualisierungen de Codes gibt und dieser mit Git „installiert“ wurde, kann der Server mit git pull origin main aktualisiert werden.

UnitTesting

Um Code testen zu können, ist zunächst PHPUnit zu installieren:

composer require --dev phpunit/phpunit
composer require --dev symfony/phpunit-bridge

Die Namen der Klassen, in denen die Testfunktionen enthalten sind, müssen dem Namen der Klasse Test anhängen: zuTestendeKlasseTest. Darüber hinaus muss diese Klasse die Klasse TestCase erweitern.

Die Testfunktionen selbst müssen mit test beginnen: public function testTestfunktion().

Aufgerufen werden die erstellten Tests mit php bin/phpunit.

Sollen nicht alle Tests durchlaufen werden, kann gefiltert werden: php bin/phpunit --filter testNameDesTests.