Bei der Vielzahl an Fullstack-CMS ist die Auswahl des passenden CMS für Applikationen im Netz nicht einfach. Teil eins dieser Artikelserie zeigt, dass Contentmanagement nicht zwingend ein aufgelegtes Fullstack CMS benötigt. Hier bietet das Symfony CMF ähnliche Funktionen für das Contentmanagement an.
Applikationen im Netz, z. B. für einen Shop, brauchen CMS-Funktionen. Viele Anwendungen laufen sogar mit einem Contentmanagementsystem im Hintergrund, obwohl Content nicht im Fokus der Seite steht. Einfache Textblöcke, ganze Seiten – sobald wir anfangen, HTML zur Wiederverwendung zu speichern, sind wir schon nah dran. Sollen solche Seiten oder Blöcke editierbar sein, sind wir definitiv im Aufgabenbereich eines CMS.
Am Ende gehört aber noch mehr zu den Funktionen eines CMS. Welche davon unsere Anwendung wirklich braucht, hängt ganz vom Umfeld des Projekts ab. Schaut man sich die verschiedenen Fullstack-CMS auf dem Markt an, sind die Möglichkeiten, aber auch die Unterschiede inzwischen sehr groß. Man könnte sagen, „da ist für jeden etwas dabei“. Die Entscheidung für das richtige Fullstack-CMS ist schon nicht einfach. Aber müssen wir denn unbedingt ein ganzes CMS aufsetzen? Was wenn wir schon eine bestehende Applikation haben? Wenn es sich hierbei noch um eine Symfony-Applikation handelt, würde ich euch in diesem und im folgenden Artikel eine Lösung vorschlagen. Auch wenn wir am Beispiel einer Symfony-Applikation arbeiten werden, soll das Nutzer anderer Frameworks nicht vom Lesen abhalten. Denn es geht auch um Konzepte, wie ihr Content handhabt, ohne ein vollständiges CMS aufzusetzen.
Wir werden in den Beispielen immer wieder dem sogenannten Document Manager begegnen. Analog zum Object Manager für das ORM von Doctrine haben wir hier einen Manager für die PHPCR-Version von Doctrine geschaffen. Da wir hier durchgehend mit Dokumenten hantieren, sprechen wir vom Document Manager. Bis auf einige zusätzliche Methoden für die Übersetzungen hat der Document Manager dasselbe Interface wie der Object Manager. Methoden wie persist(), find() oder flush() sollten geübten ORM-Nutzern bekannt vorkommen.
Beim PHPCR handelt es sich aber nicht um eine neue Datenbank. Vielmehr ist PHPCR ein Interface, um hierarchisch strukturierte Daten auf eine einheitliche Art und Weise zugänglich zu machen. Am Ende läuft PHPCR wie das ORM beim Anwender auf einem MySQL oder ähnlichem System. Das heißt für die Persistenz der folgenden Beispiele müssen wir keine zusätzliche Datenbank aufsetzen, wenn unsere Anwendung diese so oder so schon bereitstellt. Die Abhängigkeiten dafür sind dann mithilfe von Symfony Flex und einem passenden Rezept schon aufgesetzt.
Wenn es um CMS und dessen Funktionalität geht, geht es in erster Linie um die Frage: Setzen wir ein ganzes CMS auf oder bauen wir uns die nötigen Funktionen selbst zusammen? Natürlich hat jedes der CMS, die wir auf dem Markt haben, einen Bereich, in dem es besonders gut wirkt. Doch wenn wir bereits eine Symfony-Applikation haben, lassen sich dessen Funktionen schlecht mit einem CMS kombinieren. Und meist ist es schwierig, nur einen Teil der Routen an das CMS zu geben und einen Teil in der ursprünglichen Anwendung zu belassen. Solche gemischten Szenarien treten auf, wenn wir nur bestimmte Funktionen eines CMS benötigen. Im ersten Teil soll gezeigt werden wie man vorgeht, wenn man „nur mal eben schnell“ Blöcke aus HTML persistieren möchte. Dabei sollten die Blöcke editierbar und wiederverwendbar sein. Spätestens wenn Entwickler eingesetzt werden müssen, um Texte in der Webapplikation zu ändern, sollte man anfangen, solchen Content in die Datenbank zu legen.
Es fängt an mit Textblöcken, die man in schöner Symfony-Manier auch selbst in Form von sogenannten „Partials“ in eigenen Templates ablegen kann. Das fördert die Wiederverwendbarkeit dieser Codeblöcke und macht den umliegenden Code damit deutlich lesbarer:
{# main.html.twig #}
{% extends 'base.html.twig' %}
{% {% block content %}
<div class="main">
{% include 'included.html.twig' %}
</div>
{% endblock %}}
Ein Twig-Template, das importiert wird, könnte dann so aussehen:
{# included.html.twig #}
<h2>Included</h2>
<p>Dieser Inhalt wird mehrfach verwendet.</p>
Häufig sprechen wir beim Inhalt von included.html.twig von einem Snippet oder einem Partial, aber im Grunde geht es hier um einen Teil des Contents, der wiederverwendbar sein soll. Wenn sich der Text in einem Block nicht wirklich ändern soll, so ist es sinnvoll, ihn zumindest für die i18n-Internationalisierung zugänglich zu machen. Das heißt, wir würden den Textinhalt im Symfony-eigenen Translation-System ablegen. Das ist zwar noch nicht wirklich dynamisch, doch schafft es die Möglichkeit, zentral an Inhalte zu gelangen.
Nun wird es selten ausreichen, sich nur um einen Block zu kümmern. In der Realität sind es doch meist mehr. Für unser Beispiel, sagen wir mal, haben wir nur drei Blöcke. Diese sollen nun nacheinander ausgespielt und in einer Datenbank gespeichert werden. Wir geben jedem Block jeweils einen Namen: A, B, und C und persistieren den Inhalt wie hier angedeutet:
INSERT INTO blocks ('id', 'name', 'content') VALUES
('1', 'A', 'Inhalt von Block A'),
('2', 'B', 'Inhalt von Block B'),
('3', 'C', 'Inhalt von Block C');
Wir benutzen hier bewusst zusätzlich zur id ein Feld mit dem Namen name, damit wir nicht im Template oder im zugehörigen Controller direkt einen Primary Key einer Datenbanktabelle nutzen müssen. Um solch ein Set an Blöcken in einem Template zu nutzen, muss man den Inhalt irgendwoher beziehen. Mithilfe einer Twig-Helper-Funktion könnte das in etwa aussehen wie in Listing 1.
Listing 1
{% extends 'base.html.twig' %}
{% set list = {'A', 'B', 'C'} %}
{% {% block content %}
<ul>
{% {% for name in list %}
{% set block=getBlockByName(name) %}
<li>
{{ block->getContent() }}
</li>
{% endfor %}}
</ul>
{% endblock %}}
Das ist Code, der sonst in einen Controller gehört, hier wollen wir die Funktionen so nah wie möglich beieinander haben. Angemerkt sei hier, dass der Controller nicht immer der richtige Ort ist (Kasten: „Controller als Ort für die Interaktion mit der Datenbank“).
Selbst der Controller ist nicht ganz der richtige Ort für die Interaktion mit der Datenbank. Im Grunde gehört das Aggregieren in einen Service, den wir Block-Repository nennen würden. Und damit beginnt bereits die Reise in ein sogenanntes Content-Repository. Hinter dieser Schnittstelle versuchen wir Methoden zu kapseln, die sich um die Persistenz von Contentdokumenten kümmern. Zum einem dienen diese Methoden dem einheitlichen Zugriff auf Dokumente jeglicher Art. Zum anderen geht es hier abermals um die Wiederverwendbarkeit. Denn wir wollen beispielsweise Datenbankabfragen nicht ständig wiederholend und womöglich gleich in jedem Controller hinterlegen.
Nun kommt es vor, dass der Inhalt hier nicht nur in Form seines Textes gespeichert werden soll. Denn auch die Reihenfolge kann ausschlaggebend sein. Dazu kommt die Struktur in der man die Blöcke anordnen will. Die Reihenfolge könnte man über eine neue Spalte in der zugehörigen Datenbanktabelle lösen. Bei der Struktur käme nur eine baumartige Anordnung in Frage. Auch das bekommt man selbstständig in seiner Datenbank hin. Doch eventuell sind wir hier nun an einem Punkt angelangt, wo man auf ein Framework zurückgreifen sollte. Darum wollen wir uns im Folgenden die Funktionen des Symfony CMF anschauen. Denn das CMF hat sich darauf spezialisiert, solche Aufgaben rund um die Persistenz von hierarchisch strukturierten Daten durch geeignete Helferlein zu unterstützen. Dazu bringt es Funktionen mit, die man im täglichen Leben eines CMS benötigt. Dazu gehören der Support von Publishing-Workflows oder SEO. Aber auch ein dynamisches Routing sollte gefragt sein, wenn wir uns das statische Routing von Symfony vor Augen halten.
Zu allererst schauen wir uns an, wie man mithilfe von Symfony Flex die Grundfunktionen des Symfony CMF in unsere Symfony-Applikation ziehen kann. Wie in der Einleitung erwähnt, braucht man sich keine Gedanken um das Datenbankschema oder gar eine neue Datenbank selbst zu machen. Eine Symfony-Applikation und eine Datenbank, die sich mit Doctrine DBAL steuern lässt (MySQL, PostgreSQL, …), ist alles, was wir brauchen.
Dann legen wir mal los. Zum Skizzieren der Symfony-Applikation setzen wir einfach eine leere Skeleton-App auf:
composer create-project symfony/skeleton application
cd application
composer req webserver orm ormfixtures
composer req symfony-cmf/cmf-phpcr-dbal-pack
Mit Hilfe von Symfony Flex und einem Rezept für rudimentäre Pakete des CMF haben wir nun bereits alles, was wir brauchen. Wenn die Applikation bereits mit Doctrine und dem ORM arbeitet, wird das ORM zusammen mit dem DataFixtureBundle geladen. Mit diesem Bundle wollen wir nun unsere ersten Blöcke einfach per Hand erstellen. Denn das DataFixtureBundle macht nichts anderes, als die Datenbank zu leeren um dann alles im Ordner DataFixtures\PHPCR auszuführen. Diese Klassen implementieren das FixtureInterface aus dem Doctrine\Common-Namespace. Im Codebeispiel in Listing 2 sehen wir eine solche Klasse.
Listing 2
<?php
namespace App\DataFixtures\PHPCR;
use Doctrine\Common\DataFixtures\FixtureInterface;
use Doctrine\Common\Persistence\ObjectManager;
use Doctrine\ODM\PHPCR\DocumentManager;
use PHPCR\Util\NodeHelper;
use Symfony\Cmf\Bundle\BlockBundle\Doctrine\Phpcr\ContainerBlock;
use Symfony\Cmf\Bundle\BlockBundle\Doctrine\Phpcr\StringBlock;
class LoadBlockData implements FixtureInterface
{
/**
* @param ObjectManager|DocumentManager $manager
*/
public function load(ObjectManager $manager)
{
$rootPath = '/cms/blocks/';
NodeHelper::createPath($manager->getPhpcrSession(), $rootPath);
$rootNode = $manager->find(null, $rootPath);
$container = new ContainerBlock();
$container->setParent($rootNode);
$manager->persist($container);
foreach (['A', 'B', 'C'] as $name) {
$block = new StringBlock();
$block->setParent($container);
$block->setName('block-'.strtolower($key));
$block->setBody('Inhalt von Block '.$name);
$manager->persist($block);
}
$manager->flush();
}
}
Im Grunde stellt dieses Fixture dieselben Blöcke bereit, die wir schon in Listing 1 aufgesetzt haben. Nur benutzen wir jetzt dafür eine Blockklasse aus dem Symfony CMF Block Bundle und den DocumentManager zum Persistieren des Blocks. Im Setzen des Namens und des Parent-Dokuments lässt sich erahnen, dass jeder Block über eine Parent-Child-Struktur abgelegt wird. Damit wird beispielsweise der einzelne Block mit dem Namen name-a über einen path /cms/content/blocks/name-a erreichbar. Durch diesen Pfad bekommen wir eine Form von Struktur – nämlich eine Hierarchie. Diese scheint wie geschaffen zu sein, uns beim Anordnen von Blöcken zu unterstützen. Für das Beispiel können wir uns natürlich auch vorbereitend per Docker eine MySQL-Datenbank im Hostnetzwerk bereitstellen. Die Datenbank muss mit drei Commands initialisiert werden. Am Ende sieht die Vorbereitung aus wie in Listing 3.
Listing 3: Datenbank initialisieren
docker run -d \
--network host \
--env MYSQL_ALLOW_EMPTY_PASSWORD=yes \
--env MYSQL_DATABASE=cmf \
--env MYSQL_ROOT_PASSWORD=root \
--env MYSQL_USER="cmf" \
--env MYSQL_PASSWORD="cmf" \
--volume data_mag_mysql:/var/lib/mysql \
mysql:5.7 .
bin/console doctrine:phpcr:init:dbal --drop --force --env=dev
bin/console doctrine:phpcr:workspace:create prb --env=dev
bin/console doctrine:phpcr:repository:init --env=dev
Nun müssen wir nur die Zugangsdaten für die Datenbank in der App hinterlegen. und führen das Kommando für die Fixtures aus:
bin/console doctrine:phpcr:fixtures:load
Nun sind die drei Blöcke A, B und C angelegt. Doch wie kommen wir an sie heran? Das einfachste wäre eine kleine Ausgabe an der CLI: Der Command bin/console doctrine:phpcr:node:dump liefert uns sofort ein Ergebnis, mit dem wir einen Überblick über den Baum erhalten. Dazu noch ein Hinweis: Mit dem Parameter –props können wir uns die eines Nodes anzeigen lassen. Mit dem Parameter sollte man jedoch die Anzahl der ausgespielten Nodes verringern, um nicht den Überblick zu verlieren. Eine volle Beschreibung des Commands und dessen Möglichkeiten kann man sich mit bin/console doctrine:phpcr:node:dump anzeigen lassen.
Bisher sieht alles noch nicht nach einer wirklichen Zeitersparnis aus. Wir haben vorher einen Block gespeichert und nun auch wieder, dazu kommt die Ausgabe. Diese wird einem auch nicht abgenommen – wie auch? Das heißt, wir müssen nun noch unsere Anpassungen in unserem Template vornehmen. Da wir im ersten Beispiel nur exemplarischen Code gesehen haben, müssen wir nun erst einmal ein Controller-Template-Paar in der Applikation erstellen (Listing 4).
This Whitepaper contains an exclusive selection of PHP knowledge on over 40 pages for you to discover. Read articles from our experts on Laravel 10, CakePHP 5, Shopware 6, Clean Code, Single Page Applications with PHP and much more!
Download it now and learn more about the exciting developments in the PHP world!
Listing 4: Controller-Template-Paar in der Applikation erstellen
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
* @author Maximilian Berghoff <Maximilian.Berghoff@mayflower.de>
*/
class DefaultController extends Controller
{
/**
* @Route("/", name="home")
*/
public function index(): Response
{
return $this->render('home/index.html.twig', []);
}
}
Listing 5: Template
{# templates/home/index.html.twig #}
{% extends "base.html.twig" %}
{% block content %}
<h1>Homepage</h1>
{{ sonata_block_render({'name': '/cms/content/blocks'}) }}
{% endblock %}
In Listing 5 sehen wir dazu einen Controller, wie man ihn aus einer Symfony-Applikation kennt. Natürlich hätten wir hier auch die Controller-lose Variante nutzen können, da wir nur ein Template ausgeben wollen, doch mit dem Beispiel wird das Vorgehen einfach explizit. In Listing 5 sehen wir nun unser Template. Im Grunde stellt es das Beispiel vom Anfang des Artikels dar. Doch anstelle des Einfügens eines weiteren Templates, nutzen wir hier die View-Helper-Funktion aus dem SonataBlockBundle. Der Aufruf sonata_block_render() wurde für das Symfony CMF ergänzt, um nun auch Blöcke kaskadiert aus dem PHPCR zu rendern. Das heißt die Angabe von {’name‘: ‚/path/in/tree‘} als Parameter reicht, um einen Block oder einen Block samt Kindelementen zu rendern.
Inzwischen lohnt es sich immer mehr, einen Blick über den Tellerrand des klassischen Renderns einer Webapplikation zu werfen. Selbst contentlastige Anwendungen laufen im Browser des Clients und der Content muss über eine Schnittstelle geladen werden können. Auf der anderen Seite werden mehr und mehr Clientapplikationen durch ein sogenanntes Headless CMS gespeist. Dieses Vorgehen lohnt sich für Anwendungen, bei denen die Entscheidung für das Layout im Client und nicht im CMS getroffen werden. Als ein Beispiel könnte man hier das Ausspielen von Produktinformationen in einer Mobile-App, in einem Shop und im monatlichen Newsletter sehen. Wenn wir schon beim Klassifizieren von CMS sind. Zwischen dem Headless CMS und dem klassischen CMS, das Seiten über den normalen Request/Response-Mechanismus rendert, liegt das Decoupled CMS. Hier holt sich zwar auch eine Clientapplikation etwas vom API des CMS ab, jedoch sind es in diesem Fall bereits fertig gerenderte HTML Snippets. Das heißt das Decoupled CMS übernimmt einen Teil der Layoutentscheidung, während ein Headless CMS beinahe rohe Daten heraus gibt.
Um dieser Anforderung gerecht zu werden, kann beim Symfony CMF Content als Ressource über ein API abgegriffen werden. Dieses Vorgehen bedeutet, dass es keinen Unterschied machen soll, ob man eine Datei, ein Template oder eben einen Block samt Inhalt abfragt, die Notation ist dieselbe. Beim Symfony CMF hat man für die Implementierung der Ressourcen-Repositories sehr früh auf Puli gesetzt, das eine PHP-Implementierung des Ressourcengedankens ist. Leider ist das Projekt nie mit einem stabilen Release belohnt geworden, was die Maintainer des CMF dazu bewegte, die nötigen Interfaces direkt in den eigenen Code zu holen.
Um unsere eben definierten Blöcke nun auch an einem API bereitzustellen, benötigen wir ein zusätzliches Package. Dieses holen wir uns mit composer req symfony-cmf/resource-rest-bundle. Dazu benötigen wir noch eine minimale Konfiguration für das Bundle an sich und das Routing, die Listing 6 zu entnehmen sind.
Listing 6: Konfiguration für das Bundle und das Routing
# config/routing.yaml
_cmf_resource:
resource: '@CmfResourceRestBundle/Resources/config/routing.yml'
# config/packages/cmf_phpcr_dbal.yml
cmf_resource:
description:
enhancers: [doctrine_phpcr_odm, sylius_resource]
repositories:
default:
type: doctrine/phpcr-odm
Damit wird unser Blockcontainer nun unter http://127.0.0.1:8000/api/default/cms/content/blocks erreichbar. Den PHP-eigenen Server sollte man dafür über bin/console server:run bereits gestartet haben. Wir können auch eine Ebene tiefer schauen. Wenn wir http://127.0.0.1:8000/api/default/cms/content/blocks/block-a aufrufen, erhalten wir die Ausgabe in Listing 7, womit sich zwar schon arbeiten lässt, doch es fehlt uns hier der eigentliche Inhalt.
Listing 7
{
"repository_alias": "default",
"repository_type": "doctrine/phpcr-odm",
"payload_alias": null,
"payload_type": "Symfony\\Cmf\\Bundle\\BlockBundle\\Doctrine\\Phpcr\\StringBlock",
"path": "/cms/content/blocks/block-a",
"node_name": "block-a",
"label": "block-a",
"repository_path": "/cms/content/blocks/block-a",
"children": [],
"descriptors": []
}
Die Idee beim Ressourcenökosystem ist es, sich den Payload einer solchen Ressource mithilfe von sogenannten Enhancern zu befüllen. In der Beispielapplikation zu diesem Artikel ist ein solcher Enhancer vorbereitet. Er führt bei demselben Request zur Antwort in Listing 8.
Listing 8
{
"repository_alias": "default",
"repository_type": "doctrine/phpcr-odm",
"payload_alias": null,
"payload_type": "Symfony\\Cmf\\Bundle\\BlockBundle\\Doctrine\\Phpcr\\StringBlock",
"path": "/cms/content/blocks/block-a",
"node_name": "block-a",
"label": "block-a",
"repository_path": "/cms/content/blocks/block-a",
"children": [],
"descriptors": {
"properties": {
"body": "Inhalt von Block A"
}
}
}
Mit den Informationen über einen einzelnen Block oder dessen Container kann man nun eine Clientanwendung aufsetzen. Die Ressource liegt nun neben dem eigentlichen Payload, angereichert um Informationen zum Typ und den Pfaden im Content-Repository. Solch eine Ressource dient einzig dem Abholen der Informationen – also einem GET-Request.
Einen Aspekt möchte ich noch im Rahmen der Contentblöcke aufgreifen: Wie sieht es mit dem Publizieren aus? Ja, das Symfony CMF unterstützt uns auch in diesem Bereich. Doch zuerst möchte ich die Frage in den Raum stellen: „Was benötigt ein Contenteditor?“ Natürlich wäre ein Schalter zum Aktivieren eines Blockes ein Anfang. Womöglich kann solch ein differenziertes Handling schon überfordern. Ein weiterer wichtiger Punkt ist das zeitgesteuerte Publizieren. Das bedeutet, wir können eine Zeitspanne angeben, von wann und bis wann ein Blockelement sichtbar sein soll. Beide Funktionen sind über Interfaces aus dem CmfCoreBundle gelöst und können je nach Anforderung eingesetzt werden:
\Symfony\Cmf\Bundle\CoreBundle\PublishWorkflow\PublishableInterface
\Symfony\Cmf\Bundle\CoreBundle\PublishWorkflow\PublishTimePeriodInterface
Dafür haben wir zwei Eigenschaften auf unseren Blöcken (Listing 9). Wenn wir diese schon beim Einspielen befüllen, steuern sie, ob ein Block sichtbar oder nicht ist. Probiert es doch einfach einmal in der Beispielanwendung aus.
Listing 9
/**
* @var bool whether this content is publishable
*/
protected $publishable = true;
/**
* @var \DateTime|null publication start time
*/
protected $publishStartDate;
/**
* @var \DateTime|null Publication end time
*/
protected $publishEndDate;
In diesem Teil konnten wir nun erste Methoden kennen lernen, um Content in einer Blockstruktur zu persistieren und darzustellen. Hat man bereits ein Doctrine ORM in seiner Anwendung, fühlt es sich beinahe heimisch an, wenn es darum geht, Content in seiner diffusen Struktur abzulegen. Wir haben zwar nur einfache Beispiele für die Verschachtelung von Blöcken gesehen, doch es lassen sich damit ganze Seiten layouten oder wiederverwendbare Stücke ablegen. Und genau das ist der Punkt, an dem wir bei unserer Reise durch die Welt der CMS-Funktionen stehen, die das Symfony CMF bietet.
Im folgenden Artikel werden wir uns damit beschäftigen, wie es funktionieren kann, ganze Seiten unter die Kontrolle des CMS zu bekommen. Dafür benötigen wir nicht nur ein Feld zum Ablegen von HTML und einen WYSIWIG. Vielmehr geht es dabei auch um Routing, Navigation und am Ende auch um SEO. Denn auch dieses Thema geht uns Entwickler etwas an, da wir hier die Redakteure relativ einfach unterstützen können, um Arbeit nicht doppelt machen zu müssen.