<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.3.3">Jekyll</generator><link href="https://ext.dev/feed.xml" rel="self" type="application/atom+xml" /><link href="https://ext.dev/" rel="alternate" type="text/html" /><updated>2026-06-14T16:30:01+00:00</updated><id>https://ext.dev/feed.xml</id><title type="html">ext.dev GmbH - Ihr Partner für Webentwicklung, TYPO3, CRM-Integration und mehr</title><subtitle>Mit ext.dev GmbH, Ihrem Experten für Webentwicklung, TYPO3, CRM-Integration und selbstgehostete Lösungen, erreichen Sie Ihre Ziele im Web.</subtitle><entry><title type="html">Sortierbar, verlinkt, visuell: Suchergebnis-Listen für Weinplattformen aufwerten</title><link href="https://ext.dev/blog/2026/06/04/suchergebnislisten-weinplattformen-aufwerten/" rel="alternate" type="text/html" title="Sortierbar, verlinkt, visuell: Suchergebnis-Listen für Weinplattformen aufwerten" /><published>2026-06-04T08:00:00+00:00</published><updated>2026-06-04T08:00:00+00:00</updated><id>https://ext.dev/blog/2026/06/04/suchergebnislisten-weinplattformen-aufwerten</id><content type="html" xml:base="https://ext.dev/blog/2026/06/04/suchergebnislisten-weinplattformen-aufwerten/"><![CDATA[<h2 id="eine-gute-suche-endet-nicht-beim-treffer">Eine gute Suche endet nicht beim Treffer</h2>

<p>Eine schnelle Suche ist die halbe Miete – die andere Hälfte ist, was Besucherinnen und Besucher mit der Ergebnisliste anfangen können. In derselben Woche haben wir bei zwei Weinplattformen, <a href="https://www.vinum.eu" target="_blank">VINUM</a> und dem <a href="https://grandprixduvinsuisse.ch" target="_blank">Grand Prix du Vin Suisse</a>, die Such-Trefferlisten spürbar nutzerfreundlicher gemacht. Dieser Beitrag zeigt am echten Beispiel, wie kleine Interaktions-Verbesserungen eine Katalog- bzw. Suchseite aufwerten – und welche technischen Fallstricke dabei lauern.</p>

<p>Um die reine Geschwindigkeit der Suche ging es diesmal nicht; die hatten wir bereits in einem <a href="/blog/2024/02/11/beschleunigte-suche/">früheren Projekt mit SolR optimiert</a>. Hier steht die Bedienbarkeit der Ergebnisliste im Mittelpunkt.</p>

<h3 id="die-herausforderung">Die Herausforderung</h3>

<p>Die Trefferlisten taten, was sie sollten – aber nicht mehr. Die Ergebnisse waren fest nach dem besten Rating vorsortiert, ohne Möglichkeit für die Besucher, selbst umzusortieren. Produzenten standen als reiner Text in der Liste, obwohl zu vielen eine Website hinterlegt war. Auszeichnungen wurden als Wort (“Gold”, “Silber”) ausgegeben statt visuell erkennbar. Und beim Grand Prix du Vin Suisse blähten zwölf Spalten die Tabelle auf, von denen mehrere redundant waren.</p>

<h3 id="unsere-lösung">Unsere Lösung</h3>

<h4 id="1-sortierbare-spaltenüberschriften--und-der-pagination-fallstrick">1. Sortierbare Spaltenüberschriften – und der Pagination-Fallstrick</h4>

<p>Der Wunsch klingt simpel: Ein Klick auf eine Spaltenüberschrift sortiert die Liste nach dieser Spalte, auf- oder absteigend – nach Weinname, Jahrgang, Bewertung und so weiter. Der entscheidende technische Punkt steckt im Detail: Eine rein client-seitige Sortierung (etwa mit DataTables im Browser) funktioniert nur, wenn <em>alle</em> Treffer gleichzeitig im Seitenquelltext stehen. Beide Weinsuchen sind aber server-seitig paginiert – es liegen immer nur die aktuell angezeigten Treffer im Browser. Eine Browser-Sortierung würde also nur die sichtbare Seite umsortieren, nicht die gesamte Ergebnismenge.</p>

<p>Deshalb sortieren wir server-seitig über den vollständigen Treffersatz und paginieren erst danach. Bei VINUM geschieht das direkt in der SolR-Abfrage, beim Grand Prix du Vin Suisse über die TYPO3-/Extbase-Datenbankabfrage. Zwei Details waren uns dabei wichtig:</p>

<ul>
  <li><strong>Sicherheit:</strong> Die sortierbaren Spalten sind über eine feste Liste erlaubter Felder abgebildet. Nutzereingaben gelangen nie ungeprüft in die Sortier-Anweisung – das verhindert zuverlässig Injection-Angriffe.</li>
  <li><strong>Konsistenz:</strong> Ein erneuter Klick auf dieselbe Spalte dreht die Reihenfolge um; ein kleiner Pfeil (▲/▼) zeigt die aktive Spalte und Richtung an. Die gewählte Sortierung bleibt beim Blättern erhalten und – beim Grand Prix du Vin Suisse – gemeinsam mit allen gesetzten Filtern in der Adresse (URL), sodass sich ein sortiertes Ergebnis als Lesezeichen speichern oder weitergeben lässt.</li>
</ul>

<h4 id="2-sonderfall-auszeichnungen-rang-statt-alphabet">2. Sonderfall Auszeichnungen: Rang statt Alphabet</h4>

<p>Eine Spalte ließ sich nicht einfach alphabetisch sortieren: die Auszeichnungen. “Absteigend” bedeutet hier nicht “Z bis A”, sondern <em>beste zuerst</em>: Grosses Gold, Gold, Silber, Bronze, Nominiert, keine. Wir bilden diese Rangfolge über eine feste Zuordnung ab und sortieren diese eine Spalte bewusst in der Anwendungslogik – die übrigen Spalten laufen über die Datenbankabfrage. So bleibt die Reihenfolge fachlich korrekt, auch wenn für einzelne Medaillen-Stufen noch nicht alle Stammdaten vorhanden sind.</p>

<h4 id="3-produzenten-direkt-verlinken">3. Produzenten direkt verlinken</h4>

<p>Zu vielen Weingütern ist eine Website hinterlegt – bisher ungenutzt. Jetzt ist der Produzentenname anklickbar und öffnet die Website in einem neuen Tab. Ein Detail aus den echten Daten war hier entscheidend: Die allermeisten Adressen sind als reine Domain erfasst (z. B. <code class="language-plaintext highlighter-rouge">www.weingut.ch</code>) und nur eine Handvoll mit vorangestelltem <code class="language-plaintext highlighter-rouge">https://</code>. Ohne Schema interpretiert der Browser <code class="language-plaintext highlighter-rouge">www.weingut.ch</code> als Pfad auf der eigenen Seite – der Link liefe ins Leere. Wir ergänzen das Schema deshalb automatisch, sodass aus <code class="language-plaintext highlighter-rouge">www.weingut.ch</code> zuverlässig <code class="language-plaintext highlighter-rouge">https://www.weingut.ch</code> wird. Bereits vollständige Adressen bleiben unangetastet, und die Links tragen <code class="language-plaintext highlighter-rouge">rel="noopener noreferrer"</code> für ein sicheres Öffnen in neuen Tabs.</p>

<h4 id="4-medaillen-als-icon-statt-als-text">4. Medaillen als Icon statt als Text</h4>

<p>Statt des Wortes “Gold” zeigt die Auszeichnungsspalte nun ein Medaillen-Icon, exakt auf Texthöhe skaliert, sodass es sich harmonisch in die Zeile einfügt. Die Plattform ist zweisprachig (Deutsch/Französisch); damit das Icon in beiden Sprachen identisch erscheint, wählen wir es über einen sprachunabhängigen Schlüssel statt über den (übersetzten) Anzeigetext aus. Für unbekannte Werte fällt die Darstellung sauber auf den Text zurück, und das Bronze-Icon ist bereits hinterlegt, falls künftig eine Bronze-Medaille vergeben wird.</p>

<h4 id="5-feinschliff-der-trefferliste">5. Feinschliff der Trefferliste</h4>

<p>Schließlich haben wir die Tabelle beim Grand Prix du Vin Suisse von zwölf auf acht Spalten reduziert. Entfernt wurden vor allem redundante Angaben – etwa eine separate Jahr-Spalte, deren Wert sich bereits aus der Edition ergibt. Felder wie Farbe, Kanton und Rebsorte stehen weiterhin als Filter zur Verfügung; nur die Trefferliste selbst wurde verschlankt. Das Ergebnis ist eine ruhigere, besser scanbare Tabelle, die auch auf dem Smartphone deutlich aufgeräumter wirkt.</p>

<h3 id="das-ergebnis">Das Ergebnis</h3>

<p>Keine dieser Änderungen ist für sich genommen spektakulär – in Summe machen sie aus einer funktionalen Trefferliste aber ein Werkzeug, mit dem Besucher tatsächlich arbeiten können: sortieren nach dem, was sie interessiert, mit einem Klick zum Produzenten und Auszeichnungen auf den ersten Blick erkennbar. Genau dieser Feinschliff an der Interaktion entscheidet oft darüber, ob eine Such- oder Katalogseite als angenehm empfunden wird.</p>

<h3 id="fazit">Fazit</h3>

<p>Eine gute Suchseite ist mehr als ein schneller Index. Wer die Ergebnisliste konsequent vom Nutzen pro Treffer her denkt – sortierbar, verlinkt, visuell – holt aus bestehenden Daten erstaunlich viel zusätzlichen Mehrwert heraus, oft mit überschaubarem Aufwand.</p>

<p>Möchten Sie die Such- oder Katalogseite Ihrer Website nutzerfreundlicher machen? <a href="/kontakt/">Sprechen Sie uns an</a> – wir schauen uns Ihre Ergebnisliste gerne an.</p>]]></content><author><name>christopher.zechendorf</name></author><category term="development" /><summary type="html"><![CDATA[Wie kleine Interaktions-Verbesserungen eine Weinsuche aufwerten: sortierbare Spalten, verlinkte Produzenten und Medaillen als Icon -- bei VINUM und GPVS.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://ext.dev/img/post/suchergebnislisten-weinplattformen-aufwerten.webp" /><media:content medium="image" url="https://ext.dev/img/post/suchergebnislisten-weinplattformen-aufwerten.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Success-Story: TYPO3 13 sicher live – der Go-live von Profenster</title><link href="https://ext.dev/blog/2026/06/04/typo3-13-go-live-profenster/" rel="alternate" type="text/html" title="Success-Story: TYPO3 13 sicher live – der Go-live von Profenster" /><published>2026-06-04T08:00:00+00:00</published><updated>2026-06-04T08:00:00+00:00</updated><id>https://ext.dev/blog/2026/06/04/typo3-13-go-live-profenster</id><content type="html" xml:base="https://ext.dev/blog/2026/06/04/typo3-13-go-live-profenster/"><![CDATA[<h2 id="typo3-13-sicher-in-produktion-bringen--der-go-live-von-profenster">TYPO3 13 sicher in Produktion bringen – der Go-live von Profenster</h2>

<p><a href="https://www.profenster.de" target="_blank">Profenster</a> ist Spezialist für Fenster, Türen, Sonnenschutz und Wintergärten und betreibt seine Webpräsenz auf TYPO3. Nach dem Upgrade von TYPO3 12 auf 13 stand der entscheidende Schritt an: die Seite aus der Entwicklungsumgebung heraus sicher und stabil in den Produktivbetrieb zu bringen. Genau hier entscheidet die Konfiguration über Sicherheit, Zustellbarkeit von E-Mails und die Stabilität der Seite – und genau hier haben wir eine ganze Reihe typischer Stolperfallen ausgeräumt.</p>

<h3 id="die-herausforderung">Die Herausforderung</h3>

<p>Eine Website, die in der Entwicklung tadellos läuft, ist noch lange nicht produktionsreif. Die Konfiguration trug an vielen Stellen Entwicklungswerte, die im Live-Betrieb entweder nicht funktionieren oder ein Sicherheitsrisiko darstellen:</p>

<ul>
  <li><strong>Zugangsdaten im Repository:</strong> Datenbank- und Mailversand-Zugänge waren fest in der Konfiguration hinterlegt – auf dem Produktionsserver passten sie nicht mehr und waren obendrein nicht sicher abgelegt.</li>
  <li><strong>Mailversand ins Leere:</strong> Die Seite war auf ein reines Entwicklungs-Werkzeug für E-Mails eingestellt. In Produktion wären Kontaktformular-Anfragen und Systemmails lautlos verschwunden.</li>
  <li><strong>Sichtbare Debug-Ausgaben:</strong> Debug-Modus und Fehleranzeige waren aktiv und hätten Besuchern interne Details und Fehlermeldungen offengelegt.</li>
  <li><strong>Warnungen nach dem Versionssprung:</strong> Das maßgeschneiderte TYPO3-Paket nutzte Schnittstellen, die es in Version 13 nicht mehr gibt.</li>
  <li><strong>Build, der scheiterte:</strong> Der Produktions-Build zog das komplette, mehrere Gigabyte große Datenverzeichnis mit und brach ab.</li>
</ul>

<p>Unser Ziel: eine konkrete, nachvollziehbare Checkliste abarbeiten, sodass die Seite sicher, stabil und ohne böse Überraschungen live geht.</p>

<h3 id="unsere-lösung">Unsere Lösung</h3>

<h4 id="1-zugangsdaten-raus-aus-dem-code">1. Zugangsdaten raus aus dem Code</h4>

<p>Datenbank- und SMTP-Zugänge werden nicht mehr fest in der Konfiguration gespeichert, sondern zur Laufzeit aus Umgebungsvariablen gelesen. Die produktiven Geheimnisse liegen ausschließlich als Deploy- bzw. CI-Secrets vor und werden über die Container-Umgebung eingespielt. Für die lokale Entwicklung greifen weiterhin sichere Standardwerte – dieselbe, eingecheckte Konfiguration funktioniert damit in Entwicklung und Produktion, ohne dass ein einziges Passwort im Repository landet.</p>

<h4 id="2-produktiver-mailversand-statt-entwicklungs-attrappe">2. Produktiver Mailversand statt Entwicklungs-Attrappe</h4>

<p>In der Entwicklung landen E-Mails in einem lokalen Auffang-Werkzeug – praktisch zum Testen, aber in Produktion ein stiller Totalausfall. Wir haben den Versand auf einen echten SMTP-Dienst umgestellt, inklusive Absenderadresse und -name. Das Zugangspasswort kommt auch hier aus einer geschützten CI-Variable, nicht aus dem Code. Damit erreichen Kontaktformular-Anfragen und Systembenachrichtigungen zuverlässig ihr Ziel – kein verlorener Lead mehr.</p>

<h4 id="3-produktions-härtung-der-konfiguration">3. Produktions-Härtung der Konfiguration</h4>

<p>Für den Live-Betrieb haben wir Debug-Modus, Fehleranzeige und die Entwickler-Freischaltung per IP konsequent deaktiviert. So bekommen Besucher im Fehlerfall keine internen Stacktraces, Pfade oder Konfigurationsdetails mehr zu sehen – ein wichtiger Baustein, sobald echter Live-Traffic auf den neuen Server trifft.</p>

<h4 id="4-sauberes-typo3-13-paket-ohne-altlasten">4. Sauberes TYPO3-13-Paket ohne Altlasten</h4>

<p>Der Versionssprung von 12 auf 13 hat im individuellen Erweiterungspaket Warnungen ausgelöst, weil dort Schnittstellen verwendet wurden, die in Version 13 entfernt wurden – etwa für den Seitentitel in den Social-Media-Metadaten oder für die Bild-Auszeichnung. Wir haben diese Stellen auf die unterstützten Wege der Version 13 umgestellt (Seitentitel aus der Site-Konfiguration, Bildpfade aus der regulären Bildverarbeitung) und eine nicht initialisierte Variable bereinigt. Das Frontend rendert seitdem sauber, ohne Warnungen im Log.</p>

<h4 id="5-schlanker-reproduzierbarer-build">5. Schlanker, reproduzierbarer Build</h4>

<p>Der Produktions-Build zog zuvor das komplette Arbeitsverzeichnis als Kontext mit – inklusive eines mehrere Gigabyte großen Datei-Verzeichnisses, an dem er schließlich scheiterte. Mit einer sauberen Ausschlussliste (<code class="language-plaintext highlighter-rouge">.dockerignore</code>) wandern nur noch die wirklich benötigten Dateien in den Build-Kontext. Das Ergebnis: ein schneller, schlanker und reproduzierbarer Build – ohne Datenballast und ohne versehentlich eingepackte Geheimnisse oder Datenbank-Dumps.</p>

<h3 id="das-ergebnis">Das Ergebnis</h3>

<p>Die Website von Profenster läuft seit dem Go-live stabil und sicher auf TYPO3 13:</p>

<ul>
  <li><strong>Keine Geheimnisse im Code</strong> – alle Zugangsdaten kommen aus der geschützten Umgebung.</li>
  <li><strong>Zuverlässiger Mailversand</strong> – Anfragen und Systemmails erreichen ihr Ziel.</li>
  <li><strong>Keine Informationslecks</strong> – im Fehlerfall sehen Besucher keine internen Details.</li>
  <li><strong>Sauberes Frontend</strong> – keine Warnungen mehr nach dem Versionssprung.</li>
  <li><strong>Reproduzierbare Builds</strong> – schnell, schlank und ohne Datenballast.</li>
</ul>

<h3 id="fazit">Fazit</h3>

<p>Ein erfolgreicher Go-live ist kein Zufall, sondern das Abarbeiten einer durchdachten Checkliste: Geheimnisse in die Umgebung, Mailversand auf einen echten Dienst, Debug-Ausgaben aus, veraltete Schnittstellen ersetzen und den Build von Ballast befreien. Wer einen TYPO3-Relaunch oder ein Major-Upgrade plant, sollte diese Punkte früh einplanen – statt mit einer halb konfigurierten Entwicklungsumgebung live zu gehen.</p>

<hr />

<p>Sie planen einen TYPO3-Relaunch oder ein Upgrade und möchten sicher in Produktion gehen? <a href="/kontakt/">Sprechen Sie uns an</a> – wir begleiten Ihren Go-live von der ersten Checkliste bis zum stabilen Live-Betrieb.</p>]]></content><author><name>christopher.zechendorf</name></author><category term="success-stories" /><summary type="html"><![CDATA[Wie wir die TYPO3-13-Website von Profenster sicher produktionsreif gemacht haben: Secrets aus der Umgebung, produktiver Mailversand, Härtung und v13-Fixes.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://ext.dev/img/post/typo3-13-go-live-profenster.webp" /><media:content medium="image" url="https://ext.dev/img/post/typo3-13-go-live-profenster.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">19.565 Datensätze, 1 GB Memory: XLSX-Exporte in TYPO3 mit OpenSpout streamen</title><link href="https://ext.dev/blog/2026/05/21/xlsx-export-streaming-openspout/" rel="alternate" type="text/html" title="19.565 Datensätze, 1 GB Memory: XLSX-Exporte in TYPO3 mit OpenSpout streamen" /><published>2026-05-21T09:00:00+00:00</published><updated>2026-05-21T09:00:00+00:00</updated><id>https://ext.dev/blog/2026/05/21/xlsx-export-streaming-openspout</id><content type="html" xml:base="https://ext.dev/blog/2026/05/21/xlsx-export-streaming-openspout/"><![CDATA[<h2 id="wenn-der-export-button-stumm-versagt">Wenn der Export-Button stumm versagt</h2>

<p>Im “Datenrecherche”-Backend-Modul von <a href="https://www.vinum.eu" target="_blank">VINUM.eu</a> können Redaktion und Datenpflege beliebige Treffermengen aus dem Wein- und Adressbestand als XLSX exportieren. Ein typischer Use-Case: alle Winzer aus der Schweiz, die in den letzten fünf Jahren mindestens einen 18-Punkte-Wein hatten – für einen redaktionellen Newsletter oder eine Eventeinladung.</p>

<p>Bisher lief das problemlos. Bis ein Redakteur eine Suche mit 19.565 Treffern abgesetzt hat, den Export-Button klickte – und nichts passierte.</p>

<h3 id="die-herausforderung">Die Herausforderung</h3>

<p>Aus Benutzersicht: kein Fehler, kein Download, kein Hinweis. Aus Sicht der Logs: ein PHP-Fatal, sechsmal hintereinander, weil der Redakteur den Button mehrfach gedrückt hatte:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PHP Fatal error: Allowed memory size of 1073741824 bytes exhausted
(tried to allocate 167772160 bytes)
in /var/www/html/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Collection/Cells.php
on line 395
</code></pre></div></div>

<p>Das <code class="language-plaintext highlighter-rouge">memory_limit</code> der Produktion liegt bei 1 GB – großzügig dimensioniert. PhpSpreadsheet hat es trotzdem gesprengt.</p>

<p>Die Ursache lag nicht in der Datenbankabfrage. Die war bereits gebatcht: <code class="language-plaintext highlighter-rouge">BackendSearchHelper::exportXlsx()</code> lädt die Treffer in Hunderter-Schritten über eine Pagination-Hilfsmethode. Das eigentliche Problem war die Spreadsheet-Erstellung:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$spreadsheet</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">Spreadsheet</span><span class="p">();</span>
<span class="nv">$worksheet</span> <span class="o">=</span> <span class="nv">$spreadsheet</span><span class="o">-&gt;</span><span class="nf">getActiveSheet</span><span class="p">();</span>

<span class="k">foreach</span> <span class="p">(</span><span class="nv">$paginatedResults</span> <span class="k">as</span> <span class="nv">$row</span><span class="p">)</span> <span class="p">{</span>
    <span class="nv">$worksheet</span><span class="o">-&gt;</span><span class="nf">fromArray</span><span class="p">(</span><span class="nv">$row</span><span class="p">,</span> <span class="kc">null</span><span class="p">,</span> <span class="s2">"A</span><span class="si">{</span><span class="nv">$rowIndex</span><span class="si">}</span><span class="s2">"</span><span class="p">);</span>
    <span class="nv">$rowIndex</span><span class="o">++</span><span class="p">;</span>
<span class="p">}</span>

<span class="nv">$writer</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">Xlsx</span><span class="p">(</span><span class="nv">$spreadsheet</span><span class="p">);</span>
<span class="nv">$writer</span><span class="o">-&gt;</span><span class="nf">save</span><span class="p">(</span><span class="nv">$tmpFile</span><span class="p">);</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">PhpSpreadsheet</code> hält intern die komplette <code class="language-plaintext highlighter-rouge">Cells</code>-Collection im Arbeitsspeicher, bis <code class="language-plaintext highlighter-rouge">save()</code> aufgerufen wird. Egal wie elegant man die Daten in 100er-Blöcken liest – die Mappe selbst wächst monoton mit. Bei rund 19.500 Zeilen × der vollständigen TCA-Spaltenliste war Schluss.</p>

<p>Im Frontend gab es zusätzlich ein UI-Problem: Der Export-Button wurde durch ein <code class="language-plaintext highlighter-rouge">&lt;f:if condition="{pagination.count} &lt;= 35000"&gt;</code> ausgeblendet, sobald mehr als 35.000 Treffer in der Suche standen. Bei genau 19.565 war der Button sichtbar – und scheiterte trotzdem. Schlimmer noch: Beim Überschreiten der Grenze verschwand der Button kommentarlos, ohne dem Nutzer zu erklären, warum.</p>

<h3 id="unsere-lösung">Unsere Lösung</h3>

<h4 id="1-streaming-writer-statt-in-memory-mappe">1. Streaming-Writer statt In-Memory-Mappe</h4>

<p>Wir haben den Export auf <a href="https://github.com/openspout/openspout" target="_blank">OpenSpout</a> migriert – eine Library, die XLSX-Zeilen direkt auf die Festplatte streamt, statt sie im RAM zu sammeln. Der Speicherverbrauch bleibt damit konstant, unabhängig von der Zeilenzahl.</p>

<p>Die Migration war erstaunlich überschaubar, weil sich die API-Konzepte ähneln:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">use</span> <span class="nc">OpenSpout\Common\Entity\Row</span><span class="p">;</span>
<span class="kn">use</span> <span class="nc">OpenSpout\Writer\XLSX\Writer</span><span class="p">;</span>

<span class="nv">$writer</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">Writer</span><span class="p">();</span>
<span class="nv">$writer</span><span class="o">-&gt;</span><span class="nf">openToFile</span><span class="p">(</span><span class="nv">$tmpFile</span><span class="p">);</span>
<span class="nv">$writer</span><span class="o">-&gt;</span><span class="nf">addRow</span><span class="p">(</span><span class="nc">Row</span><span class="o">::</span><span class="nf">fromValues</span><span class="p">(</span><span class="nv">$headers</span><span class="p">));</span>

<span class="k">foreach</span> <span class="p">(</span><span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">iterateResults</span><span class="p">(</span><span class="nv">$table</span><span class="p">,</span> <span class="nv">$repository</span><span class="p">,</span> <span class="nv">$search</span><span class="p">)</span> <span class="k">as</span> <span class="nv">$row</span><span class="p">)</span> <span class="p">{</span>
    <span class="nv">$writer</span><span class="o">-&gt;</span><span class="nf">addRow</span><span class="p">(</span><span class="nc">Row</span><span class="o">::</span><span class="nf">fromValues</span><span class="p">(</span><span class="nv">$row</span><span class="p">));</span>
<span class="p">}</span>

<span class="nv">$writer</span><span class="o">-&gt;</span><span class="nf">close</span><span class="p">();</span>
</code></pre></div></div>

<p>Statt einer <code class="language-plaintext highlighter-rouge">Spreadsheet</code>-Instanz wird ein <code class="language-plaintext highlighter-rouge">Writer</code> geöffnet, der direkt in den Output-Stream schreibt. Jeder <code class="language-plaintext highlighter-rouge">addRow()</code>-Aufruf flusht die Zeile, der Speicher bleibt im einstelligen MB-Bereich – auch bei 100.000 Zeilen.</p>

<p>Wichtig: <code class="language-plaintext highlighter-rouge">openspout/openspout</code> haben wir explizit in der <code class="language-plaintext highlighter-rouge">composer.json</code> der <code class="language-plaintext highlighter-rouge">backendsearch</code>-Extension deklariert. Vorher griff die Extension auf PhpSpreadsheet zu, das nur transitive Abhängigkeit der Haupt-Extension <code class="language-plaintext highlighter-rouge">vinum</code> war. Solche impliziten Vendor-Abhängigkeiten sind eine tickende Zeitbombe: Wenn die übergeordnete Extension irgendwann auf eine andere Library wechselt, bricht der Export ohne sichtbaren Anlass.</p>

<h4 id="2-ehrliches-ui-gate">2. Ehrliches UI-Gate</h4>

<p>Das <code class="language-plaintext highlighter-rouge">&lt;f:if&gt;</code> blendet den Button nicht mehr stumm aus, sondern ersetzt ihn durch eine erklärende Zeile:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;f:if</span> <span class="na">condition=</span><span class="s">"{pagination.count} &lt;= {settings.maxExportRows}"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;f:then&gt;</span>
        <span class="nt">&lt;f:render</span> <span class="na">section=</span><span class="s">"ExportButton"</span> <span class="na">arguments=</span><span class="s">"{_all}"</span> <span class="nt">/&gt;</span>
    <span class="nt">&lt;/f:then&gt;</span>
    <span class="nt">&lt;f:else&gt;</span>
        <span class="nt">&lt;p</span> <span class="na">class=</span><span class="s">"text-muted"</span><span class="nt">&gt;</span>
            <span class="nt">&lt;f:translate</span>
                <span class="na">key=</span><span class="s">"export.tooManyResults"</span>
                <span class="na">arguments=</span><span class="s">"{0: '{settings.maxExportRows}'}"</span> <span class="nt">/&gt;</span>
        <span class="nt">&lt;/p&gt;</span>
    <span class="nt">&lt;/f:else&gt;</span>
<span class="nt">&lt;/f:if&gt;</span>
</code></pre></div></div>

<p>Auf Deutsch: <em>“Export nur bis 35.000 Treffer möglich. Bitte Suche eingrenzen.”</em> Der Schwellwert ist konfigurierbar, das tatsächliche Limit wird im Text genannt. Mit OpenSpout könnten wir die Obergrenze theoretisch deutlich höher legen – pragmatisch belassen wir sie aber bei 35.000, weil ein Export dieser Größenordnung in Excel ohnehin nicht mehr sinnvoll zu bearbeiten ist.</p>

<h4 id="3-diagnose-pfad-dokumentieren">3. Diagnose-Pfad dokumentieren</h4>

<p>Was bei diesem Fix unterschätzt wird: Der größere Aufwand steckte nicht in der Code-Änderung, sondern in der Diagnose. Ohne den Blick in <code class="language-plaintext highlighter-rouge">fpm-stderr.log</code> wäre die Ursache nicht eindeutig zuzuordnen gewesen – der Browser meldet keinen Fehler, das Backend liefert eine leere Antwort, und der Button “tut einfach nichts”.</p>

<p>Wir haben den Diagnose-Pfad daher in der Projektdokumentation festgehalten: Welche Logs für welche Symptome relevant sind, welche <code class="language-plaintext highlighter-rouge">memory_limit</code>- und <code class="language-plaintext highlighter-rouge">max_execution_time</code>-Werte produktiv gelten, und wie man PHP-Fatals einer konkreten Action im Backend zuordnet. Das spart bei der nächsten ähnlichen Meldung schlicht Zeit.</p>

<h3 id="das-ergebnis">Das Ergebnis</h3>

<ul>
  <li>Der ursprünglich gescheiterte Export mit 19.565 Treffern läuft jetzt durch – ohne PHP-Fatal, mit konstantem Speicherverbrauch.</li>
  <li>Suchen oberhalb des konfigurierten Limits zeigen einen verständlichen Hinweistext statt eines verschwindenden Buttons.</li>
  <li>Die <code class="language-plaintext highlighter-rouge">backendsearch</code>-Extension hat ihre Abhängigkeit zu Excel-Libraries jetzt sauber in der eigenen <code class="language-plaintext highlighter-rouge">composer.json</code> deklariert.</li>
  <li>Die öffentliche API des Helpers (<code class="language-plaintext highlighter-rouge">exportXlsx(string $table, object $repository, array $search): string</code>) ist unverändert – der Refactoring lief vollständig innerhalb der Implementierung.</li>
</ul>

<h3 id="wann-lohnt-sich-der-wechsel">Wann lohnt sich der Wechsel?</h3>

<p>PhpSpreadsheet ist eine ausgezeichnete Library, wenn man Formatierungen, Formeln, Diagramme oder mehrere Sheets benötigt. Für reine Datentabellen-Exporte mit größeren Treffermengen ist OpenSpout aber fast immer die bessere Wahl: schneller, schlanker, und ohne harte Obergrenze beim Datenvolumen.</p>

<p>Wenn Sie ähnliche Probleme mit Exporten, Imports oder generell speicherintensiven Backend-Modulen in TYPO3 haben, sprechen Sie uns an. Oft genügen punktuelle Eingriffe an den richtigen Stellen, um aus einem “geht nicht mehr” wieder ein “läuft zuverlässig” zu machen.</p>

<p><a href="/kontakt/">Kontakt aufnehmen</a></p>]]></content><author><name>christopher.zechendorf</name></author><category term="performance" /><summary type="html"><![CDATA[PhpSpreadsheet ist bequem, aber speicherhungrig. Wie wir den XLSX-Export in einem TYPO3-Backend-Modul auf OpenSpout umgestellt und damit Memory-Limits eliminiert haben.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://ext.dev/img/post/xlsx-export-streaming-openspout.webp" /><media:content medium="image" url="https://ext.dev/img/post/xlsx-export-streaming-openspout.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">VINUM Archiv-Seite: Von 320 Magazincovern zu Lazy-Loading mit Jahresfilter</title><link href="https://ext.dev/blog/2026/05/21/vinum-archiv-lazy-loading-jahresfilter/" rel="alternate" type="text/html" title="VINUM Archiv-Seite: Von 320 Magazincovern zu Lazy-Loading mit Jahresfilter" /><published>2026-05-21T08:00:00+00:00</published><updated>2026-05-21T08:00:00+00:00</updated><id>https://ext.dev/blog/2026/05/21/vinum-archiv-lazy-loading-jahresfilter</id><content type="html" xml:base="https://ext.dev/blog/2026/05/21/vinum-archiv-lazy-loading-jahresfilter/"><![CDATA[<h2 id="320-cover-auf-einen-schlag--und-keines-davon-klein">320 Cover auf einen Schlag – und keines davon klein</h2>

<p>Das Online-Archiv von <a href="https://www.vinum.eu" target="_blank">VINUM.eu</a> listet alle bisher erschienenen Magazin-Ausgaben seit 2014. Über die Jahre sind dort 356 Ausgaben zusammengekommen, 320 davon mit hochauflösendem Cover. Optisch beeindruckend – technisch problematisch.</p>

<h3 id="die-herausforderung">Die Herausforderung</h3>

<p>Ein Blick in die Produktion hat den Ist-Zustand schnell sichtbar gemacht:</p>

<ul>
  <li><strong>320 Cover-Dateien</strong>, davon 110 als PNG (Schnitt 2,1 MB, max. 15 MB) und 210 als JPG (Schnitt 5,9 MB, max. 22,7 MB).</li>
  <li>Alle Cover wurden in einer einzigen Response in voller Originalgröße ausgeliefert.</li>
  <li>Der Jahresfilter im Dropdown war rein clientseitig implementiert: Über JavaScript wurden die nicht passenden Karten per <code class="language-plaintext highlighter-rouge">display: none</code> ausgeblendet – die Bilder im DOM blieben aber geladen.</li>
  <li>Kein einziges <code class="language-plaintext highlighter-rouge">loading="lazy"</code> an einem <code class="language-plaintext highlighter-rouge">&lt;img&gt;</code>-Tag.</li>
</ul>

<p>Im ungünstigsten Fall musste der Browser also über 1 GB an Bildmaterial nachladen, nur um eine einzige Ausgabe anzuzeigen. Auf Mobilgeräten war das Archiv kaum benutzbar, und selbst auf dem Desktop dauerte das initiale Rendering mehrere Sekunden.</p>

<p>Interessant: Im ursprünglichen Kundenticket war von “den großen PNGs” die Rede. Die Datenbankanalyse hat aber klar gezeigt, dass die JPGs die eigentlichen Schwergewichte waren. Ohne diesen Blick in die Produktionsdaten wäre die Optimierung auf die falsche Stelle gelaufen.</p>

<h3 id="unsere-lösung">Unsere Lösung</h3>

<h4 id="1-jahresfilter-vom-browser-in-den-controller-verlegen">1. Jahresfilter vom Browser in den Controller verlegen</h4>

<p>Statt 320 Karten zu rendern und 285 davon im Browser zu verstecken, filtert jetzt der Server. Die <code class="language-plaintext highlighter-rouge">listAction</code> im <code class="language-plaintext highlighter-rouge">IssueController</code> akzeptiert einen optionalen <code class="language-plaintext highlighter-rouge">?year</code>-Parameter und liefert standardmäßig nur das aktuelle Jahr aus – aktuell 35 Ausgaben statt 320:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">function</span> <span class="n">listAction</span><span class="p">(</span><span class="kt">?string</span> <span class="nv">$year</span> <span class="o">=</span> <span class="kc">null</span><span class="p">):</span> <span class="kt">ResponseInterface</span>
<span class="p">{</span>
    <span class="nv">$years</span> <span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">issueRepository</span><span class="o">-&gt;</span><span class="nf">findYears</span><span class="p">();</span>
    <span class="nv">$selectedYear</span> <span class="o">=</span> <span class="nv">$year</span> <span class="o">??</span> <span class="p">(</span><span class="n">string</span><span class="p">)(</span><span class="nv">$years</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="o">??</span> <span class="s1">''</span><span class="p">);</span>
    <span class="nv">$issues</span> <span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">issueRepository</span><span class="o">-&gt;</span><span class="nf">findByYear</span><span class="p">(</span><span class="nv">$selectedYear</span><span class="p">);</span>

    <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">view</span><span class="o">-&gt;</span><span class="nf">assignMultiple</span><span class="p">([</span>
        <span class="s1">'years'</span> <span class="o">=&gt;</span> <span class="nv">$years</span><span class="p">,</span>
        <span class="s1">'selectedYear'</span> <span class="o">=&gt;</span> <span class="nv">$selectedYear</span><span class="p">,</span>
        <span class="s1">'issues'</span> <span class="o">=&gt;</span> <span class="nv">$issues</span><span class="p">,</span>
    <span class="p">]);</span>

    <span class="k">return</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">htmlResponse</span><span class="p">();</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Im Repository werden die Jahre über einen <code class="language-plaintext highlighter-rouge">QueryBuilder</code> ermittelt – bewusst nicht über Extbase, um nicht jedes Issue-Objekt zu hydratieren, nur um daraus eine Jahreszahl zu lesen:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">function</span> <span class="n">findYears</span><span class="p">():</span> <span class="kt">array</span>
<span class="p">{</span>
    <span class="nv">$queryBuilder</span> <span class="o">=</span> <span class="nc">GeneralUtility</span><span class="o">::</span><span class="nf">makeInstance</span><span class="p">(</span><span class="nc">ConnectionPool</span><span class="o">::</span><span class="n">class</span><span class="p">)</span>
        <span class="o">-&gt;</span><span class="nf">getQueryBuilderForTable</span><span class="p">(</span><span class="s1">'tx_vinum_domain_model_issue'</span><span class="p">);</span>

    <span class="k">return</span> <span class="nv">$queryBuilder</span>
        <span class="o">-&gt;</span><span class="nf">select</span><span class="p">(</span><span class="s1">'year'</span><span class="p">)</span>
        <span class="o">-&gt;</span><span class="nf">distinct</span><span class="p">()</span>
        <span class="o">-&gt;</span><span class="nf">from</span><span class="p">(</span><span class="s1">'tx_vinum_domain_model_issue'</span><span class="p">)</span>
        <span class="o">-&gt;</span><span class="nf">where</span><span class="p">(</span>
            <span class="nv">$queryBuilder</span><span class="o">-&gt;</span><span class="nf">expr</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">neq</span><span class="p">(</span><span class="s1">'cover_image'</span><span class="p">,</span> <span class="nv">$queryBuilder</span><span class="o">-&gt;</span><span class="nf">createNamedParameter</span><span class="p">(</span><span class="s1">''</span><span class="p">)),</span>
            <span class="nv">$queryBuilder</span><span class="o">-&gt;</span><span class="nf">expr</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">eq</span><span class="p">(</span><span class="s1">'deleted'</span><span class="p">,</span> <span class="mi">0</span><span class="p">),</span>
            <span class="nv">$queryBuilder</span><span class="o">-&gt;</span><span class="nf">expr</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">eq</span><span class="p">(</span><span class="s1">'hidden'</span><span class="p">,</span> <span class="mi">0</span><span class="p">),</span>
        <span class="p">)</span>
        <span class="o">-&gt;</span><span class="nf">orderBy</span><span class="p">(</span><span class="s1">'year'</span><span class="p">,</span> <span class="s1">'DESC'</span><span class="p">)</span>
        <span class="o">-&gt;</span><span class="nf">executeQuery</span><span class="p">()</span>
        <span class="o">-&gt;</span><span class="nf">fetchFirstColumn</span><span class="p">();</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Das Dropdown im Template wird serverseitig mit <code class="language-plaintext highlighter-rouge">&lt;f:form.select&gt;</code> befüllt und submittet beim <code class="language-plaintext highlighter-rouge">change</code>-Event. Deep-Links wie <code class="language-plaintext highlighter-rouge">/archiv/?tx_vinum_issues[year]=2024</code> funktionieren ohne <code class="language-plaintext highlighter-rouge">cHash</code>-Fehler, weil die Argumente getypt sind.</p>

<h4 id="2-cover-breite-deckeln-und-auf-jpg-normalisieren">2. Cover-Breite deckeln und auf JPG normalisieren</h4>

<p>Der bestehende <code class="language-plaintext highlighter-rouge">SafeImageViewHelper</code> der VINUM-Plattform unterstützt bereits <code class="language-plaintext highlighter-rouge">maxWidth</code>, <code class="language-plaintext highlighter-rouge">fileExtension</code> und <code class="language-plaintext highlighter-rouge">loading</code>-Attribute – die wurden nur noch nie genutzt:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;vinum:safeImage</span>
    <span class="na">src=</span><span class="s">"{issue.coverImage.uid}"</span>
    <span class="na">treatIdAsReference=</span><span class="s">"true"</span>
    <span class="na">maxWidth=</span><span class="s">"700"</span>
    <span class="na">fileExtension=</span><span class="s">"jpg"</span>
    <span class="na">loading=</span><span class="s">"lazy"</span> <span class="nt">/&gt;</span>
</code></pre></div></div>

<p>Drei Hebel in einer Zeile:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">maxWidth="700"</code> reduziert übergroße Originale auf eine sinnvolle Darstellungsgröße. Kleinere Originale werden nicht hochskaliert.</li>
  <li><code class="language-plaintext highlighter-rouge">fileExtension="jpg"</code> zwingt TYPO3, auch PNGs als JPG auszuspielen. Für Magazincover, die ohnehin keine Transparenzen enthalten, ist das deutlich effizienter.</li>
  <li><code class="language-plaintext highlighter-rouge">loading="lazy"</code> ist das native Lazy-Loading der Browser – kein JavaScript, kein Framework, keine Abhängigkeit.</li>
</ul>

<p>Die Re-Encoding-Qualität haben wir global in <code class="language-plaintext highlighter-rouge">typo3/config/system/settings.php</code> festgesetzt:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="s1">'GFX'</span> <span class="o">=&gt;</span> <span class="p">[</span>
    <span class="s1">'jpg_quality'</span> <span class="o">=&gt;</span> <span class="mi">90</span><span class="p">,</span>
<span class="p">],</span>
</code></pre></div></div>

<p>90 ist ein bewährter Wert für Cover-Bilder: visuell kaum vom Original zu unterscheiden, aber typischerweise 5–10× kleiner als das JPG-Original aus der Druckvorstufe.</p>

<h4 id="3-client-filter-behutsam-zurückbauen">3. Client-Filter behutsam zurückbauen</h4>

<p>Im Frontend-JavaScript (<code class="language-plaintext highlighter-rouge">Archive.js</code>) blieb der Filter nach Ausgabennummer erhalten – der arbeitet jetzt auf maximal 35 Karten pro Jahr und ist daher unkritisch. Entfernt wurde nur die Logik, die das Jahres-Dropdown clientseitig befüllt und die Cover-Karten ein- und ausblendet. Weniger Code, weniger Sonderfälle.</p>

<h3 id="das-ergebnis">Das Ergebnis</h3>

<p>Die Archivseite lädt nun in einem Bruchteil der ursprünglichen Zeit:</p>

<ul>
  <li>Statt 320 Cover werden initial nur die ~35 Ausgaben des aktuellen Jahres geladen.</li>
  <li>Jedes Cover ist auf maximal 700 px Breite normalisiert, als JPG mit Qualität 90, und wird nativ lazy geladen.</li>
  <li>Deep-Links auf konkrete Jahre funktionieren ohne JavaScript und bleiben für Suchmaschinen indexierbar.</li>
  <li>Die originalen Cover-Dateien im <code class="language-plaintext highlighter-rouge">fileadmin</code> bleiben unangetastet – die kleineren Varianten landen ausschließlich im TYPO3-Image-Cache.</li>
</ul>

<p>Spannend an diesem Projekt war weniger die einzelne Technik (Lazy-Loading, ImageProcessor, server-seitiger Filter sind alle Standard) als der Weg dorthin: Erst die Produktionsdaten analysieren, dann die Annahmen aus dem Ticket prüfen – und dann gezielt am richtigen Hebel ansetzen.</p>

<h3 id="brauchen-sie-eine-ähnliche-optimierung">Brauchen Sie eine ähnliche Optimierung?</h3>

<p>Performance-Probleme auf gewachsenen TYPO3-Seiten lassen sich fast immer auf eine Handvoll klassischer Muster zurückführen: clientseitige Filter über vollständige Listen, unkomprimierte Bilder, fehlendes Lazy-Loading, ungebatchte Repository-Calls. Wir analysieren Ihre konkrete Situation – inklusive Blick in die Datenbank und Logs – und benennen die Hebel mit dem besten Aufwand-Nutzen-Verhältnis.</p>

<p><a href="/kontakt/">Kontakt aufnehmen</a></p>]]></content><author><name>christopher.zechendorf</name></author><category term="performance" /><summary type="html"><![CDATA[Wie wir die Ladezeit der VINUM-Archivseite drastisch verbessert haben -- durch serverseitigen Jahresfilter, Lazy-Loading und JPG-Re-Encoding in TYPO3.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://ext.dev/img/post/vinum-archiv-lazy-loading-jahresfilter.webp" /><media:content medium="image" url="https://ext.dev/img/post/vinum-archiv-lazy-loading-jahresfilter.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Defense in Depth: Sieben Sicherheitsebenen für TYPO3-Produktionsserver</title><link href="https://ext.dev/blog/2026/04/03/defense-in-depth-sieben-sicherheitsebenen-typo3/" rel="alternate" type="text/html" title="Defense in Depth: Sieben Sicherheitsebenen für TYPO3-Produktionsserver" /><published>2026-04-03T08:00:00+00:00</published><updated>2026-04-03T08:00:00+00:00</updated><id>https://ext.dev/blog/2026/04/03/defense-in-depth-sieben-sicherheitsebenen-typo3</id><content type="html" xml:base="https://ext.dev/blog/2026/04/03/defense-in-depth-sieben-sicherheitsebenen-typo3/"><![CDATA[<h2 id="die-herausforderung">Die Herausforderung</h2>

<p>In einem <a href="/blog/2026/04/02/vulnerability-scanner-absicherung/">früheren Beitrag</a> haben wir gezeigt, wie ein einzelner Vulnerability Scanner eine TYPO3-Website lahmlegen konnte — nicht durch einen DDoS-Angriff, sondern durch die Erschöpfung von PHP-FPM-Workern. Das Rate Limit allein reichte nicht aus. Erst die Kombination aus Connection Limiting und fail2ban löste das Problem.</p>

<p>Dieser Fall illustriert ein grundlegendes Prinzip der IT-Sicherheit: <strong>Defense in Depth</strong>. Keine einzelne Schutzmassnahme ist perfekt. Jede Ebene hat blinde Flecken — was die Firewall nicht sieht, fängt das Rate Limiting ab; was das Rate Limiting nicht erkennt, blockiert die Intrusion Detection. Die Stärke liegt in der Kombination.</p>

<p>Für unsere TYPO3-Produktionsumgebungen setzen wir sieben aufeinander aufbauende Sicherheitsebenen ein, die wir im Folgenden durchgehen.</p>

<h2 id="sieben-ebenen-im-detail">Sieben Ebenen im Detail</h2>

<h3 id="1-betriebssystem-härtung">1. Betriebssystem-Härtung</h3>

<p>Die erste Verteidigungslinie ist der Server selbst. Über Cloud-Init wird jeder neue Server automatisch mit einer gehärteten Grundkonfiguration provisioniert:</p>

<p><strong>SSH-Härtung:</strong></p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
MaxAuthTries 3
ClientAliveInterval 300
ClientAliveCountMax 2
</code></pre></div></div>

<p>Root-Login ist deaktiviert, Passwort-Authentifizierung verboten. Nur Public-Key-Authentifizierung ist erlaubt, mit maximal 3 Fehlversuchen. Inaktive Verbindungen werden nach 10 Minuten getrennt — das verhindert vergessene SSH-Sessions als potenzielle Angriffsfläche.</p>

<p><strong>Firewall (UFW):</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ufw default deny incoming
ufw default allow outgoing
ufw allow ssh
ufw allow 80/tcp
ufw allow 443/tcp
</code></pre></div></div>

<p>Standardmässig wird jeglicher eingehender Traffic blockiert. Nur drei Ports sind offen: SSH für Administration, HTTP und HTTPS für Web-Traffic. Kein MySQL-Port, kein Redis-Port, kein phpMyAdmin — diese Dienste sind ausschliesslich über die interne Docker-Netzwerkkommunikation erreichbar.</p>

<p><strong>Automatische Sicherheitsupdates:</strong> Unattended Upgrades prüfen täglich auf Patches und installieren Sicherheitsupdates automatisch. Bei Kernel-Updates erfolgt ein Reboot um 4:00 Uhr morgens. Docker-Pakete sind von automatischen Updates ausgenommen, da ein unerwartetes Docker-Upgrade laufende Container beeinträchtigen könnte.</p>

<h3 id="2-netzwerkisolation">2. Netzwerkisolation</h3>

<p>Die Docker-Compose-Konfiguration definiert zwei getrennte Netzwerke:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">networks</span><span class="pi">:</span>
  <span class="na">backend</span><span class="pi">:</span>
    <span class="na">internal</span><span class="pi">:</span> <span class="kc">true</span>
  <span class="na">frontend</span><span class="pi">:</span>
</code></pre></div></div>

<p>Das <code class="language-plaintext highlighter-rouge">backend</code>-Netzwerk ist als <code class="language-plaintext highlighter-rouge">internal</code> markiert — Container in diesem Netzwerk haben keinen Zugang zum Internet und sind von aussen nicht erreichbar. MariaDB und Redis befinden sich ausschliesslich in diesem Netzwerk. Nur der TYPO3-Container hat Zugang zu beiden Netzwerken und fungiert als einziger Zugangspunkt.</p>

<p><strong>Warum das wichtig ist:</strong> Selbst wenn ein Angreifer Code-Ausführung im TYPO3-Container erlangt, kann er die Datenbank nicht direkt aus dem Internet ansprechen. Es gibt keinen offenen Port, keinen Weg am Application Layer vorbei. Ein <code class="language-plaintext highlighter-rouge">nmap</code>-Scan auf dem Host zeigt nur Port 80, 443 und 22 — die Datenbankports existieren auf Netzwerkebene schlicht nicht.</p>

<h3 id="3-tls-härtung">3. TLS-Härtung</h3>

<p>Die Verschlüsselung zwischen Browser und Server ist die nächste Schicht:</p>

<div class="language-nginx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">ssl_protocols</span> <span class="s">TLSv1.2</span> <span class="s">TLSv1.3</span><span class="p">;</span>
<span class="k">ssl_ciphers</span> <span class="s">'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:...'</span><span class="p">;</span>
<span class="k">ssl_stapling</span> <span class="no">on</span><span class="p">;</span>
<span class="k">ssl_stapling_verify</span> <span class="no">on</span><span class="p">;</span>
<span class="k">add_header</span> <span class="s">Strict-Transport-Security</span> <span class="s">"max-age=31536000</span><span class="p">;</span> <span class="k">includeSubDomains</span><span class="p">;</span> <span class="k">preload"</span> <span class="s">always</span><span class="p">;</span>
</code></pre></div></div>

<p><strong>TLS 1.2 und 1.3 ausschliesslich</strong> — ältere Versionen mit bekannten Schwächen (POODLE, BEAST) sind deaktiviert. Die Cipher Suite priorisiert Forward Secrecy (ECDHE) und moderne Algorithmen (ChaCha20, AES-GCM).</p>

<p><strong>OCSP Stapling</strong> beschleunigt die Zertifikatsprüfung: Statt dass der Browser den Zertifikatsstatus bei der CA abfragen muss, liefert der Server die signierte Bestätigung gleich mit. Das spart einen Roundtrip und verhindert Privacy-Leaks an die CA.</p>

<p><strong>HSTS mit Preload</strong> sorgt dafür, dass Browser die Domain ausschliesslich über HTTPS laden — auch beim ersten Besuch, wenn der Nutzer <code class="language-plaintext highlighter-rouge">http://</code> eingibt. Das <code class="language-plaintext highlighter-rouge">preload</code>-Flag registriert die Domain in den HSTS-Preload-Listen der Browser, sodass selbst der allererste HTTP-Request nie unverschlüsselt gesendet wird.</p>

<p><strong>Automatische Zertifikats-Verwaltung:</strong> Let’s-Encrypt-Zertifikate werden beim Container-Start automatisch angefordert und zweimal täglich per Cron erneuert. Kein manuelles Eingreifen, keine abgelaufenen Zertifikate.</p>

<h3 id="4-web-server-härtung">4. Web-Server-Härtung</h3>

<p>Nginx bildet die zentrale Verteidigung auf Applikationsebene:</p>

<p><strong>Rate Limiting</strong> begrenzt jeden Client auf 5 Requests pro Sekunde mit einem Burst von 15. Das verhindert Brute-Force-Angriffe und bremst automatisierte Scanner. TYPO3-Backend-Dateiuploads (AJAX-Requests) sind vom Rate Limit ausgenommen, damit Redakteure nicht beim Hochladen grosser Dateien blockiert werden.</p>

<p><strong>Blockierte Angriffspfade</strong> schliessen bekannte Einfallstore:</p>

<div class="language-nginx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Versteckte Dateien (.env, .git, .htaccess)</span>
<span class="k">location</span> <span class="p">~</span> <span class="sr">/\.(?!well-known\/)</span> <span class="p">{</span> <span class="kn">deny</span> <span class="s">all</span><span class="p">;</span> <span class="p">}</span>

<span class="c1"># PHP-Ausführung in Upload-Verzeichnissen</span>
<span class="k">location</span> <span class="p">~</span><span class="sr">*</span> <span class="s">^/(?:fileadmin|typo3conf|typo3temp|uploads)/.*</span><span class="err">\</span><span class="s">.php</span>$ <span class="p">{</span> <span class="kn">deny</span> <span class="s">all</span><span class="p">;</span> <span class="p">}</span>

<span class="c1"># Konfigurationsdateien und Backups</span>
<span class="k">location</span> <span class="p">~</span> <span class="sr">/(?:composer\.(?:json|lock)|.*\.(?:bak|conf|cfg|sql|log|sh))$</span> <span class="p">{</span> <span class="kn">deny</span> <span class="s">all</span><span class="p">;</span> <span class="p">}</span>

<span class="c1"># TYPO3-interne Verzeichnisse</span>
<span class="k">location</span> <span class="p">~</span> <span class="sr">/(?:typo3conf/ext|typo3/sysext)/[^/]+/(?:Resources/Private|Tests)/</span> <span class="p">{</span> <span class="kn">deny</span> <span class="s">all</span><span class="p">;</span> <span class="p">}</span>
</code></pre></div></div>

<p>Die Regel für Upload-Verzeichnisse ist besonders kritisch: Wenn ein Angreifer eine PHP-Datei in <code class="language-plaintext highlighter-rouge">fileadmin/</code> hochladen kann (z.B. über eine Schwachstelle im Datei-Upload), wird sie von Nginx nicht an PHP-FPM weitergeleitet, sondern blockiert. Die hochgeladene Datei ist nutzlos.</p>

<p><strong>Security Headers</strong> ergänzen den Schutz auf HTTP-Ebene:</p>

<div class="language-nginx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">X-Content-Type-Options:</span> <span class="s">nosniff</span>      <span class="c1"># Verhindert MIME-Type-Sniffing</span>
<span class="s">X-Frame-Options:</span> <span class="s">SAMEORIGIN</span>           <span class="c1"># Clickjacking-Schutz</span>
<span class="s">Referrer-Policy:</span> <span class="s">strict-origin-when-cross-origin</span>
</code></pre></div></div>

<p><strong>Server-Identität verbergen:</strong> Der <code class="language-plaintext highlighter-rouge">X-Powered-By</code>-Header, den PHP standardmässig sendet, wird über <code class="language-plaintext highlighter-rouge">fastcgi_hide_header</code> entfernt. Ein Angreifer soll nicht ohne Weiteres erkennen, welche PHP-Version läuft.</p>

<h3 id="5-intrusion-detection">5. Intrusion Detection</h3>

<p><a href="https://github.com/fail2ban/fail2ban" target="_blank">fail2ban</a> überwacht die Nginx-Access-Logs und erkennt typische Scanner-Muster:</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[Definition]</span>
<span class="py">failregex</span> <span class="p">=</span> <span class="s">^&lt;HOST&gt; -.*"(GET|POST) /</span><span class="se">\.</span><span class="s">env[^</span><span class="se">\s</span><span class="s">]* HTTP/.*$</span>
            <span class="err">^&lt;HOST&gt;</span> <span class="err">-.*"(GET|POST)</span> <span class="err">/wp-(admin|content|includes|json|login)</span><span class="nn">[^\s]</span><span class="err">*</span> <span class="err">HTTP/.*$</span>
            <span class="err">^&lt;HOST&gt;</span> <span class="err">-.*"(GET|POST)</span> <span class="err">/xmlrpc\.php</span><span class="nn">[^\s]</span><span class="err">*</span> <span class="err">HTTP/.*$</span>
            <span class="err">^&lt;HOST&gt;</span> <span class="err">-.*"(GET|POST)</span> <span class="err">/administrator</span><span class="nn">[^\s]</span><span class="err">*</span> <span class="err">HTTP/.*$</span>
            <span class="err">^&lt;HOST&gt;</span> <span class="err">-.*"(GET|POST)</span> <span class="err">/phpmyadmin</span><span class="nn">[^\s]</span><span class="err">*</span> <span class="err">HTTP/.*$</span>
            <span class="err">^&lt;HOST&gt;</span> <span class="err">-.*"(GET|POST)</span> <span class="err">/\.git</span><span class="nn">[^\s]</span><span class="err">*</span> <span class="err">HTTP/.*$</span>
</code></pre></div></div>

<p>Jede IP, die innerhalb von 60 Sekunden 5 dieser Muster auslöst, wird für eine Stunde per <code class="language-plaintext highlighter-rouge">nftables</code>-Firewall-Regel auf allen Ports gesperrt. Der Scanner erhält keinen einzigen Response mehr — nicht einmal ein <code class="language-plaintext highlighter-rouge">403 Forbidden</code>.</p>

<p><strong>Warum <code class="language-plaintext highlighter-rouge">nftables</code> statt HTTP-Block?</strong> Ein HTTP-Level-Block (z.B. <code class="language-plaintext highlighter-rouge">deny</code> in Nginx) verbraucht trotzdem Server-Ressourcen: Nginx muss die Verbindung annehmen, den Request parsen und eine Antwort senden. Eine Firewall-Regel verwirft die Pakete bereits auf Kernel-Ebene, bevor sie den Userspace erreichen. Bei einem aggressiven Scanner spart das messbar CPU-Last.</p>

<h3 id="6-applikations-härtung">6. Applikations-Härtung</h3>

<p>Innerhalb des Containers ist PHP selbst gehärtet:</p>

<p><strong>OPcache</strong> mit 256 MB Speicher und 10.000 gecachten Dateien sorgt dafür, dass PHP-Dateien nur einmal kompiliert werden. Das verbessert nicht nur die Performance, sondern verhindert auch, dass ein zur Laufzeit modifiziertes Script sofort ausgeführt wird — der OPcache liefert die kompilierte Version aus dem Speicher, bis <code class="language-plaintext highlighter-rouge">revalidate_freq</code> erreicht ist.</p>

<p><strong>Ressourcen-Limits</strong> auf Container-Ebene verhindern, dass ein einzelner Service den gesamten Server lahmlegt. Die Limits werden vom Setup-Script berechnet und als Docker <code class="language-plaintext highlighter-rouge">deploy.resources</code> gesetzt:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">deploy</span><span class="pi">:</span>
  <span class="na">resources</span><span class="pi">:</span>
    <span class="na">limits</span><span class="pi">:</span>
      <span class="na">memory</span><span class="pi">:</span> <span class="s">2G</span>
      <span class="na">cpus</span><span class="pi">:</span> <span class="s2">"</span><span class="s">1.1"</span>
    <span class="na">reservations</span><span class="pi">:</span>
      <span class="na">memory</span><span class="pi">:</span> <span class="s">819M</span>
</code></pre></div></div>

<p>Überschreitet ein Container sein Memory-Limit, wird er von Docker beendet und automatisch neu gestartet (<code class="language-plaintext highlighter-rouge">restart: unless-stopped</code>). Das ist besser als ein OOM-Kill auf Host-Ebene, der unkontrolliert Prozesse beenden könnte.</p>

<p><strong>PHP-FPM Worker-Berechnung:</strong> Die Anzahl der Worker wird automatisch an den verfügbaren Speicher angepasst — ein Worker pro 80 MB. Das verhindert sowohl Worker-Erschöpfung (zu wenige) als auch Speicherüberläufe (zu viele).</p>

<h3 id="7-monitoring-und-log-management">7. Monitoring und Log-Management</h3>

<p>Die letzte Ebene stellt sicher, dass Probleme erkannt werden, bevor sie zu Ausfällen führen:</p>

<p><strong>Health Checks</strong> überwachen die Dienste. Redis wird alle 10 Sekunden per <code class="language-plaintext highlighter-rouge">redis-cli ping</code> geprüft. Nach 5 fehlgeschlagenen Checks startet Docker den Container automatisch neu.</p>

<p><strong>Log Rotation</strong> verhindert, dass Logs die Festplatte füllen:</p>
<ul>
  <li>PHP-Fehler: wöchentlich rotiert, 8 Wochen Aufbewahrung</li>
  <li>TYPO3-Logs: täglich rotiert, 14 Tage Aufbewahrung</li>
  <li>Docker-Container-Logs: maximal 10 MB pro Datei, 3 Dateien pro Service</li>
</ul>

<p><strong>Scheduler-Lock-Reset:</strong> Beim Container-Start werden alle blockierten TYPO3-Scheduler-Tasks zurückgesetzt. Wird ein Container während eines laufenden Cronjobs beendet (z.B. bei einem Deployment), bleiben die Tasks sonst dauerhaft als “running” markiert und werden nie wieder ausgeführt. Der Entrypoint-Script erkennt diesen Zustand und bereinigt ihn automatisch.</p>

<h2 id="das-zusammenspiel">Das Zusammenspiel</h2>

<p>Die Stärke von Defense in Depth liegt nicht in der einzelnen Massnahme, sondern im Zusammenspiel:</p>

<table>
  <thead>
    <tr>
      <th>Angriffsvektor</th>
      <th>Ebene 1–2</th>
      <th>Ebene 3–4</th>
      <th>Ebene 5–7</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>SSH-Brute-Force</td>
      <td>UFW + SSH-Härtung</td>
      <td>—</td>
      <td>fail2ban sperrt nach 3 Versuchen</td>
    </tr>
    <tr>
      <td>Vulnerability Scanner</td>
      <td>Firewall blockiert unbekannte Ports</td>
      <td>Rate Limiting bremst, Pfade blockiert</td>
      <td>fail2ban sperrt per Muster</td>
    </tr>
    <tr>
      <td>Hochgeladene PHP-Shell</td>
      <td>—</td>
      <td>Nginx blockiert PHP in Upload-Dirs</td>
      <td>Logs zeigen den Versuch</td>
    </tr>
    <tr>
      <td>SQL Injection</td>
      <td>Netzwerkisolation begrenzt Schaden</td>
      <td>—</td>
      <td>Container-Limits verhindern Eskalation</td>
    </tr>
    <tr>
      <td>Abgelaufenes Zertifikat</td>
      <td>—</td>
      <td>Automatische Erneuerung</td>
      <td>Certbot-Logs bei Fehler</td>
    </tr>
  </tbody>
</table>

<p>Selbst wenn eine Ebene versagt, greifen die anderen. Ein Scanner, der das Rate Limit unterläuft (wie in unserem <a href="/blog/2026/04/02/vulnerability-scanner-absicherung/">dokumentierten Fall</a>), wird trotzdem von fail2ban erkannt. Ein PHP-Prozess, der ausser Kontrolle gerät, wird durch das Container-Memory-Limit begrenzt.</p>

<h2 id="fazit">Fazit</h2>

<p>Sicherheit ist kein Feature, das man einmal konfiguriert. Es ist ein System aus sich ergänzenden Massnahmen, das regelmässig überprüft und erweitert wird. Unser Boilerplate automatisiert dieses System, sodass jedes neue Projekt automatisch alle sieben Ebenen erhält — ohne manuellen Aufwand und ohne die Möglichkeit, eine Ebene zu vergessen.</p>

<p>Die hier beschriebenen Massnahmen sind nicht TYPO3-spezifisch. Dieselben Prinzipien gelten für jede PHP-Anwendung hinter Nginx — ob WordPress, Symfony oder Laravel. Der Ansatz ändert sich nicht, nur die Applikationsschicht.</p>

<hr />

<p>Möchten Sie wissen, wie gut Ihre aktuelle Server-Konfiguration gegen diese Angriffsvektoren geschützt ist? Wir analysieren Ihre Infrastruktur und identifizieren Lücken. Nehmen Sie über unser <a href="/kontakt/">Kontaktformular</a> unverbindlich Kontakt mit uns auf.</p>]]></content><author><name>christopher.zechendorf</name></author><category term="development" /><summary type="html"><![CDATA[Warum eine einzelne Schutzmassnahme nie ausreicht — und wie wir TYPO3-Produktionsserver mit sieben aufeinander aufbauenden Sicherheitsebenen absichern.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://ext.dev/img/post/defense-in-depth-sieben-sicherheitsebenen-typo3.webp" /><media:content medium="image" url="https://ext.dev/img/post/defense-in-depth-sieben-sicherheitsebenen-typo3.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Gehärtetes TYPO3-Hosting: Unser Boilerplate für sichere Produktionsumgebungen</title><link href="https://ext.dev/blog/2026/04/03/gehaertetes-typo3-hosting-boilerplate/" rel="alternate" type="text/html" title="Gehärtetes TYPO3-Hosting: Unser Boilerplate für sichere Produktionsumgebungen" /><published>2026-04-03T07:00:00+00:00</published><updated>2026-04-03T07:00:00+00:00</updated><id>https://ext.dev/blog/2026/04/03/gehaertetes-typo3-hosting-boilerplate</id><content type="html" xml:base="https://ext.dev/blog/2026/04/03/gehaertetes-typo3-hosting-boilerplate/"><![CDATA[<h2 id="die-herausforderung">Die Herausforderung</h2>

<p>Jedes neue TYPO3-Projekt braucht eine Produktionsumgebung. Server aufsetzen, Docker konfigurieren, Nginx härten, SSL einrichten, Firewall-Regeln definieren, CI/CD-Pipeline bauen — das sind pro Projekt schnell mehrere Tage Arbeit. Und bei jedem manuellen Schritt schleichen sich potenzielle Fehler ein: ein vergessener Security Header, eine fehlende Rate-Limiting-Regel, PHP-FPM-Worker die nicht zum verfügbaren RAM passen.</p>

<p>Wir haben über die letzten Jahre für jedes Kundenprojekt dieselben Bausteine konfiguriert — mit leichten Variationen je nach Servergrösse und Domain. Die Frage war: Können wir diesen Prozess so standardisieren, dass jedes neue Projekt automatisch den gleichen gehärteten Standard bekommt?</p>

<h2 id="unsere-lösung">Unsere Lösung</h2>

<p>Wir haben ein TYPO3-Boilerplate entwickelt, das mit einem einzigen <code class="language-plaintext highlighter-rouge">setup.sh</code>-Aufruf ein vollständiges, produktionsreifes Projekt generiert. Das Script fragt Domain, Projektname und Server-Ressourcen ab und erzeugt daraus die komplette Infrastruktur — von Docker Compose über Nginx-Konfiguration bis zur GitLab-CI/CD-Pipeline.</p>

<h3 id="architektur-drei-container-zwei-netzwerke">Architektur: Drei Container, zwei Netzwerke</h3>

<p>Die generierte Umgebung besteht aus drei Services in Docker Compose:</p>

<ul>
  <li><strong>TYPO3</strong> (PHP 8.4-FPM + Nginx + Supervisor) — der Applikations-Container</li>
  <li><strong>MariaDB 10.11</strong> — die Datenbank</li>
  <li><strong>Redis 7</strong> — Cache-Backend mit LRU-Eviction</li>
</ul>

<p>Entscheidend ist die Netzwerktrennung: MariaDB und Redis befinden sich ausschliesslich im internen <code class="language-plaintext highlighter-rouge">backend</code>-Netzwerk. Nur der TYPO3-Container hat Zugang zu beiden Netzwerken — <code class="language-plaintext highlighter-rouge">backend</code> für die Datenbank-Kommunikation und <code class="language-plaintext highlighter-rouge">frontend</code> für HTTP/HTTPS-Traffic. Die Datenbank ist damit von aussen nicht erreichbar, auch nicht über einen Portscan auf dem Host.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">networks</span><span class="pi">:</span>
  <span class="na">backend</span><span class="pi">:</span>
    <span class="na">internal</span><span class="pi">:</span> <span class="kc">true</span>  <span class="c1"># Kein Zugang von aussen</span>
  <span class="na">frontend</span><span class="pi">:</span>
</code></pre></div></div>

<p>Für die lokale Entwicklung stehen zusätzlich phpMyAdmin und MailHog als Docker-Profiles zur Verfügung — sie werden nur mit <code class="language-plaintext highlighter-rouge">COMPOSE_PROFILES=development</code> gestartet und sind in Staging/Production nicht vorhanden.</p>

<h3 id="intelligente-ressourcenberechnung">Intelligente Ressourcenberechnung</h3>

<p>Einer der Kernaspekte des Boilerplates ist die automatische Berechnung von Docker-Ressourcenlimits basierend auf dem verfügbaren Server-RAM. Das Setup-Script fragt die RAM-Grösse für Staging und Production ab und generiert daraus jeweils eine <code class="language-plaintext highlighter-rouge">docker-compose.staging.yml</code> und <code class="language-plaintext highlighter-rouge">docker-compose.production.yml</code> mit passenden Limits.</p>

<p>Die Verteilung folgt einem festen Schema:</p>

<table>
  <thead>
    <tr>
      <th>Komponente</th>
      <th>RAM-Anteil</th>
      <th>CPU-Anteil</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>MariaDB</td>
      <td>35%</td>
      <td>25%</td>
    </tr>
    <tr>
      <td>TYPO3 (PHP-FPM)</td>
      <td>50%</td>
      <td>55%</td>
    </tr>
    <tr>
      <td>Redis</td>
      <td>5% (min. 128 MB)</td>
      <td>10%</td>
    </tr>
    <tr>
      <td>Betriebssystem</td>
      <td>10%</td>
      <td>—</td>
    </tr>
  </tbody>
</table>

<p>Aus dem TYPO3-Anteil berechnet das Script zusätzlich die optimale Anzahl PHP-FPM-Worker — ein Worker pro 80 MB verfügbarem Speicher. Bei einem 4-GB-Server ergeben sich so ca. 25 Worker, bei 8 GB etwa 51. Die <code class="language-plaintext highlighter-rouge">pm.start_servers</code>, <code class="language-plaintext highlighter-rouge">pm.min_spare_servers</code> und <code class="language-plaintext highlighter-rouge">pm.max_spare_servers</code>-Werte werden proportional dazu gesetzt.</p>

<p>Auch die MariaDB-Konfiguration wird angepasst: Der InnoDB Buffer Pool erhält 60% des Datenbank-Limits, die InnoDB Log File Size skaliert mit der RAM-Grösse (64 MB bis 256 MB). Ab 16 GB RAM werden zusätzliche Performance-Optionen wie <code class="language-plaintext highlighter-rouge">tmp-table-size</code>, <code class="language-plaintext highlighter-rouge">join-buffer-size</code> und Performance Schema aktiviert.</p>

<p>Das Ergebnis: Kein manuelles Tuning mehr. Die Ressourcen-Konfiguration ist immer auf den jeweiligen Server abgestimmt und verhindert sowohl OOM-Kills als auch ungenutzte Kapazität.</p>

<h3 id="automatische-server-provisionierung">Automatische Server-Provisionierung</h3>

<p>Für neue Server liefert das Boilerplate eine Cloud-Init-Konfiguration mit, die den Server beim ersten Start automatisch härtet:</p>

<ul>
  <li><strong>SSH-Härtung</strong>: Root-Login deaktiviert, nur Public-Key-Authentifizierung, maximal 3 Login-Versuche</li>
  <li><strong>UFW-Firewall</strong>: Standardmässig alles blockiert, nur SSH, HTTP und HTTPS offen</li>
  <li><strong>fail2ban</strong>: SSH-Schutz mit maximal 3 Fehlversuchen, plus ein eigener Jail für Vulnerability Scanner</li>
  <li><strong>Automatische Sicherheitsupdates</strong>: Unattended Upgrades mit täglicher Prüfung und automatischem Reboot um 4:00 Uhr morgens — Docker-Pakete sind von Updates ausgenommen, um unerwartete Container-Probleme zu vermeiden</li>
  <li><strong>Deploy-User</strong>: Ein dedizierter <code class="language-plaintext highlighter-rouge">deploy</code>-Benutzer mit Docker-Zugang für CI/CD-Deployments</li>
</ul>

<p>Der gesamte Prozess läuft ohne manuellen Eingriff. Nach dem Boot ist der Server produktionsbereit.</p>

<h3 id="nginx-konfiguration-mit-security-by-default">Nginx-Konfiguration mit Security-by-Default</h3>

<p>Das Boilerplate enthält drei Nginx-Konfigurationen — Development, Staging und Production — die der Container beim Start je nach <code class="language-plaintext highlighter-rouge">TYPO3_CONTEXT</code>-Umgebungsvariable automatisch aktiviert. Die Production-Konfiguration umfasst:</p>

<p><strong>TLS-Härtung:</strong></p>
<ul>
  <li>Ausschliesslich TLS 1.2 und 1.3</li>
  <li>Moderne Cipher Suite (ECDHE + ChaCha20 + AES-GCM)</li>
  <li>OCSP Stapling für schnellere Zertifikatsprüfung</li>
  <li>HSTS mit <code class="language-plaintext highlighter-rouge">includeSubDomains</code> und <code class="language-plaintext highlighter-rouge">preload</code> (1 Jahr)</li>
</ul>

<p><strong>Rate Limiting:</strong></p>
<ul>
  <li>5 Requests pro Sekunde pro IP</li>
  <li>Burst von 15 für kurzzeitige Spitzen</li>
  <li>TYPO3-Backend-Dateiuploads sind vom Rate Limit ausgenommen</li>
</ul>

<p><strong>Blockierte Angriffspfade:</strong></p>
<ul>
  <li>Versteckte Dateien und Verzeichnisse (<code class="language-plaintext highlighter-rouge">.git</code>, <code class="language-plaintext highlighter-rouge">.env</code>, <code class="language-plaintext highlighter-rouge">.svn</code>)</li>
  <li>PHP-Ausführung in Upload-Verzeichnissen (<code class="language-plaintext highlighter-rouge">fileadmin</code>, <code class="language-plaintext highlighter-rouge">typo3temp</code>, <code class="language-plaintext highlighter-rouge">uploads</code>)</li>
  <li>Zugriff auf <code class="language-plaintext highlighter-rouge">composer.json</code>, Konfigurationsdateien, Logs und SQL-Dumps</li>
  <li>TYPO3-interne Verzeichnisse (<code class="language-plaintext highlighter-rouge">Resources/Private</code>, <code class="language-plaintext highlighter-rouge">Tests</code>, <code class="language-plaintext highlighter-rouge">Documentation</code>)</li>
</ul>

<p><strong>Performance:</strong></p>
<ul>
  <li>Aggressive Browser-Caching-Regeln: CSS, JS, Bilder und Fonts mit 1 Jahr Laufzeit und <code class="language-plaintext highlighter-rouge">immutable</code>-Flag</li>
  <li>Gzip-Komprimierung für 26+ Content-Types</li>
  <li>Support für versionierte Dateinamen (z.B. <code class="language-plaintext highlighter-rouge">style.abc123.css</code>)</li>
</ul>

<h3 id="automatische-ssl-zertifikate">Automatische SSL-Zertifikate</h3>

<p>Der Entrypoint-Script des TYPO3-Containers kümmert sich selbstständig um Let’s-Encrypt-Zertifikate. Beim ersten Start erkennt er, ob bereits Zertifikate vorhanden sind. Falls nicht, startet er zunächst eine temporäre HTTP-only-Konfiguration, fordert über Certbot ein Zertifikat an und wechselt dann zur vollständigen HTTPS-Konfiguration. Die Zertifikate werden in einem Docker Volume persistiert und zweimal täglich per Cron-Job erneuert.</p>

<p>Dieser Mechanismus funktioniert identisch für Staging und Production — der einzige Unterschied ist die Domain. Manuelle Zertifikats-Verwaltung entfällt komplett.</p>

<h3 id="cicd-pipeline">CI/CD-Pipeline</h3>

<p>Die generierte <code class="language-plaintext highlighter-rouge">.gitlab-ci.yml</code> definiert zwei Deployment-Stages:</p>

<ul>
  <li><strong>Staging</strong>: Deployment bei Push auf den <code class="language-plaintext highlighter-rouge">staging</code>-Branch</li>
  <li><strong>Production</strong>: Deployment bei Push auf den <code class="language-plaintext highlighter-rouge">main</code>-Branch</li>
</ul>

<p>Beide Stages folgen demselben Ablauf:</p>
<ol>
  <li>Repository per <code class="language-plaintext highlighter-rouge">rsync</code> auf den Server synchronisieren (Runtime-Verzeichnisse und <code class="language-plaintext highlighter-rouge">.env</code> werden ausgeschlossen)</li>
  <li><code class="language-plaintext highlighter-rouge">.env</code>-Datei aus GitLab-CI-Variablen generieren (Passwörter liegen nie im Repository)</li>
  <li>Ressourcen-Override als <code class="language-plaintext highlighter-rouge">docker-compose.override.yml</code> aktivieren</li>
  <li>Container neu bauen und starten</li>
  <li>fail2ban-Konfigurationen auf den Host deployen</li>
  <li>TYPO3-Caches leeren</li>
</ol>

<h3 id="log-rotation-und-monitoring">Log Rotation und Monitoring</h3>

<p>Logs werden auf drei Ebenen rotiert:</p>
<ul>
  <li><strong>PHP-Fehler</strong>: Wöchentlich, 8 Wochen Aufbewahrung</li>
  <li><strong>TYPO3-Applikationslogs</strong>: Täglich, 14 Tage Aufbewahrung</li>
  <li><strong>Docker-Container-Logs</strong>: Maximal 10 MB pro Datei, 3 Dateien pro Service</li>
</ul>

<p>Redis hat einen eingebauten Health Check (10-Sekunden-Intervall), der Container-Neustarts bei Problemen auslöst. Der Entrypoint-Script setzt beim Start alle blockierten Scheduler-Tasks zurück — ein häufiges Problem, wenn Container während laufender Cronjobs beendet werden.</p>

<h2 id="das-ergebnis">Das Ergebnis</h2>

<p>Was früher Tage manueller Konfiguration erforderte, ist jetzt ein 5-Minuten-Prozess:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>./setup.sh
<span class="c"># → Domain, Titel, Server-RAM eingeben</span>
<span class="c"># → Fertiges Projekt mit allen Konfigurationen</span>
<span class="nb">cd</span> ../client.tld
docker compose up <span class="nt">-d</span>
</code></pre></div></div>

<p>Jedes Projekt startet mit demselben gehärteten Standard — unabhängig davon, ob es auf einem 2-GB-VPS oder einem 32-GB-Dedicated-Server läuft. Die Ressourcen-Konfiguration passt sich automatisch an, die Sicherheitsmassnahmen sind identisch.</p>

<p>Das Boilerplate ist ein lebendes System: Erkenntnisse aus dem Betrieb — wie die <a href="/blog/2026/04/02/vulnerability-scanner-absicherung/">Absicherung gegen Vulnerability Scanner</a>, die wir kürzlich beschrieben haben — fliessen zurück in die Standardkonfiguration. Jedes neue Projekt profitiert automatisch von den Erfahrungen aller bestehenden Projekte.</p>

<hr />

<p>Sie suchen eine Agentur, die nicht nur TYPO3-Websites entwickelt, sondern auch das Hosting und die Infrastruktur professionell betreut? Sprechen Sie uns über unser <a href="/kontakt/">Kontaktformular</a> an — wir beraten Sie gerne.</p>]]></content><author><name>christopher.zechendorf</name></author><category term="development" /><summary type="html"><![CDATA[Wie wir mit einem einzigen Setup-Script produktionsreife TYPO3-Umgebungen generieren — mit Docker, Nginx-Härtung, automatischer Ressourcenberechnung und CI/CD.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://ext.dev/img/post/gehaertetes-typo3-hosting-boilerplate.webp" /><media:content medium="image" url="https://ext.dev/img/post/gehaertetes-typo3-hosting-boilerplate.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Vulnerability Scanner als DDoS: Webserver gegen aggressive Bots absichern</title><link href="https://ext.dev/blog/2026/04/02/vulnerability-scanner-absicherung/" rel="alternate" type="text/html" title="Vulnerability Scanner als DDoS: Webserver gegen aggressive Bots absichern" /><published>2026-04-02T09:00:00+00:00</published><updated>2026-04-02T09:00:00+00:00</updated><id>https://ext.dev/blog/2026/04/02/vulnerability-scanner-absicherung</id><content type="html" xml:base="https://ext.dev/blog/2026/04/02/vulnerability-scanner-absicherung/"><![CDATA[<h2 id="die-herausforderung">Die Herausforderung</h2>

<p>An einem Vormittag bemerkten wir über unser Monitoring eine erhöhte Antwortzeit bei VINUM.eu. Innerhalb weniger Minuten war die Website für reguläre Besucher nicht mehr erreichbar — Requests liefen in Timeouts. Die Ursache war kein klassischer DDoS-Angriff mit massenhaft verteiltem Traffic, sondern ein einzelner Vulnerability Scanner von der IP <code class="language-plaintext highlighter-rouge">185.177.72.50</code>.</p>

<p>Der Scanner feuerte in nur 8 Minuten rund 964 Requests gegen die Website. Dabei probierte er systematisch typische Angriffsvektoren durch: <code class="language-plaintext highlighter-rouge">.env</code>-Dateien, WordPress-Endpunkte wie <code class="language-plaintext highlighter-rouge">wp-json</code>, <code class="language-plaintext highlighter-rouge">wp-content</code> und <code class="language-plaintext highlighter-rouge">wp-admin</code>, <code class="language-plaintext highlighter-rouge">xmlrpc.php</code> und diverse andere bekannte Pfade. Keiner dieser Pfade existierte auf der TYPO3-Installation — aber genau das war das Problem.</p>

<h3 id="warum-das-rate-limit-nicht-griff">Warum das Rate Limit nicht griff</h3>

<p>Auf dem Server war bereits ein Rate Limit von 5 Requests pro Sekunde konfiguriert. Im Durchschnitt lag der Scanner mit ca. 2 Requests pro Sekunde deutlich darunter. Allerdings öffnete er viele Verbindungen gleichzeitig, die parallel auf eine Antwort warteten. Jeder dieser Requests landete bei PHP-FPM, weil nginx die nicht existierenden Pfade an TYPO3 weiterreichte — und TYPO3 brauchte für jede 404-Antwort den vollen Rendering-Durchlauf.</p>

<p>Das Ergebnis: Alle 20 konfigurierten <code class="language-plaintext highlighter-rouge">pm.max_children</code>-Worker in PHP-FPM waren belegt. Legitime Requests von echten Besuchern konnten nicht mehr bedient werden und liefen in eine Queue, die sich über ca. 15 Minuten aufbaute.</p>

<p>Das Rate Limit schützt gegen Anfragen pro Sekunde — aber nicht gegen viele gleichzeitig offene Verbindungen, die jeweils einen Worker blockieren. Ein klassischer Fall, in dem Request Rate und Connection Concurrency zwei unterschiedliche Probleme sind.</p>

<h2 id="unsere-lösung">Unsere Lösung</h2>

<p>Die Absicherung besteht aus zwei Ebenen: nginx begrenzt die gleichzeitigen Verbindungen pro IP, und fail2ban sperrt Scanner anhand ihrer typischen Request-Muster komplett aus.</p>

<h3 id="nginx-connection-limiting">Nginx Connection Limiting</h3>

<p>Zusätzlich zum bestehenden Rate Limit haben wir ein Connection Limit eingeführt. In der <code class="language-plaintext highlighter-rouge">nginx.conf</code> wird zunächst eine Shared-Memory-Zone definiert:</p>

<div class="language-nginx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">http</span> <span class="p">{</span>
    <span class="c1"># Bestehend: Rate Limit</span>
    <span class="kn">limit_req_zone</span> <span class="nv">$remote_addr</span> <span class="s">zone=per_ip_rate:10m</span> <span class="s">rate=5r/s</span><span class="p">;</span>

    <span class="c1"># Neu: Connection Limit</span>
    <span class="kn">limit_conn_zone</span> <span class="nv">$remote_addr</span> <span class="s">zone=per_ip:10m</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>In den PHP-Locations für das Frontend wird das Limit dann aktiviert:</p>

<div class="language-nginx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">location</span> <span class="p">~</span> <span class="sr">\.php$</span> <span class="p">{</span>
    <span class="c1"># Connection Limit: max. 10 gleichzeitige Verbindungen pro IP</span>
    <span class="kn">limit_conn</span> <span class="s">per_ip</span> <span class="mi">10</span><span class="p">;</span>

    <span class="c1"># Rate Limit: Burst von 15 auf 8 reduziert</span>
    <span class="kn">limit_req</span> <span class="s">zone=per_ip_rate</span> <span class="s">burst=8</span> <span class="s">nodelay</span><span class="p">;</span>

    <span class="kn">fastcgi_pass</span> <span class="s">unix:/run/php/php-fpm.sock</span><span class="p">;</span>
    <span class="c1"># ... weitere fastcgi-Parameter</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Für das TYPO3-Backend (<code class="language-plaintext highlighter-rouge">/typo3/</code>) erlauben wir etwas mehr gleichzeitige Verbindungen, da Redakteure im Backend naturgemäss mehr parallele Requests erzeugen (AJAX-Calls, Modul-Loads etc.):</p>

<div class="language-nginx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">location</span> <span class="n">/typo3/</span> <span class="p">{</span>
    <span class="kn">limit_conn</span> <span class="s">per_ip</span> <span class="mi">15</span><span class="p">;</span>
    <span class="kn">limit_req</span> <span class="s">zone=per_ip_rate</span> <span class="s">burst=8</span> <span class="s">nodelay</span><span class="p">;</span>

    <span class="c1"># ... Backend-spezifische Konfiguration</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Mit <code class="language-plaintext highlighter-rouge">limit_conn per_ip 10</code> kann eine einzelne IP maximal 10 Verbindungen gleichzeitig offen halten. Der Scanner, der dutzende parallele Connections aufbaute, wird damit auf 10 begrenzt — die restlichen Verbindungen erhalten sofort einen <code class="language-plaintext highlighter-rouge">503 Service Temporarily Unavailable</code>. Die PHP-FPM-Worker bleiben für legitime Besucher verfügbar.</p>

<h3 id="fail2ban-scanner-jail">fail2ban Scanner-Jail</h3>

<p>Das Connection Limit begrenzt den Schaden, aber ein Vulnerability Scanner hat auf der Website grundsätzlich nichts verloren. Mit einem fail2ban-Jail erkennen wir typische Scanner-Muster und sperren die IP komplett per Firewall.</p>

<p>Der Filter (<code class="language-plaintext highlighter-rouge">/etc/fail2ban/filter.d/nginx-scanner.conf</code>):</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[Definition]</span>
<span class="py">failregex</span> <span class="p">=</span> <span class="s">^&lt;HOST&gt; .* "(GET|POST|HEAD) /</span><span class="se">\.</span><span class="s">env.*"</span>
            <span class="err">^&lt;HOST&gt;</span> <span class="err">.*</span> <span class="err">"(GET|POST|HEAD)</span> <span class="err">/wp-(admin|content|includes|json|login).*"</span>
            <span class="err">^&lt;HOST&gt;</span> <span class="err">.*</span> <span class="err">"(GET|POST|HEAD)</span> <span class="err">/xmlrpc\.php.*"</span>
            <span class="err">^&lt;HOST&gt;</span> <span class="err">.*</span> <span class="err">"(GET|POST|HEAD)</span> <span class="err">/administrator/.*"</span>
            <span class="err">^&lt;HOST&gt;</span> <span class="err">.*</span> <span class="err">"(GET|POST|HEAD)</span> <span class="err">/config\.(php|yml|json|bak).*"</span>
            <span class="err">^&lt;HOST&gt;</span> <span class="err">.*</span> <span class="err">"(GET|POST|HEAD)</span> <span class="err">/\.git/.*"</span>

<span class="py">ignoreregex</span> <span class="p">=</span>
</code></pre></div></div>

<p>Das zugehörige Jail (<code class="language-plaintext highlighter-rouge">/etc/fail2ban/jail.d/nginx-scanner.conf</code>):</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[nginx-scanner]</span>
<span class="py">enabled</span>  <span class="p">=</span> <span class="s">true</span>
<span class="py">port</span>     <span class="p">=</span> <span class="s">http,https</span>
<span class="py">filter</span>   <span class="p">=</span> <span class="s">nginx-scanner</span>
<span class="py">logpath</span>  <span class="p">=</span> <span class="s">/var/log/nginx/access.log</span>
<span class="py">maxretry</span> <span class="p">=</span> <span class="s">3</span>
<span class="py">findtime</span> <span class="p">=</span> <span class="s">600</span>
<span class="py">bantime</span>  <span class="p">=</span> <span class="s">86400</span>
<span class="py">action</span>   <span class="p">=</span> <span class="s">iptables-multiport[name=scanner, port="http,https", protocol=tcp]</span>
</code></pre></div></div>

<p>Mit <code class="language-plaintext highlighter-rouge">maxretry = 3</code> und <code class="language-plaintext highlighter-rouge">findtime = 600</code> wird eine IP gesperrt, sobald sie innerhalb von 10 Minuten 3 dieser typischen Scanner-Requests absetzt. Die Sperre (<code class="language-plaintext highlighter-rouge">bantime = 86400</code>) gilt für 24 Stunden. Damit wäre der Scanner aus unserem Vorfall bereits nach seinen ersten drei Probing-Versuchen geblockt worden — weit bevor er die PHP-FPM-Worker hätte erschöpfen können.</p>

<h2 id="das-ergebnis">Das Ergebnis</h2>

<p>Die Kombination aus Connection Limiting und fail2ban bietet einen zweischichtigen Schutz:</p>

<ol>
  <li>
    <p><strong>Sofortwirkung durch Connection Limit:</strong> Selbst wenn ein Scanner noch nicht von fail2ban erkannt wurde, kann er maximal 10 gleichzeitige Verbindungen aufbauen. Die restlichen PHP-FPM-Worker bleiben für regulären Traffic verfügbar.</p>
  </li>
  <li>
    <p><strong>Nachhaltige Sperre durch fail2ban:</strong> Scanner werden anhand ihrer typischen Muster erkannt und per Firewall komplett ausgesperrt — ohne dass ein einziger PHP-FPM-Worker belastet wird.</p>
  </li>
  <li>
    <p><strong>Kein Einfluss auf reguläre Besucher:</strong> 10 gleichzeitige Verbindungen pro IP sind für normales Browsing mehr als ausreichend. Auch der reduzierte Burst-Wert von 8 schränkt reguläre Nutzer nicht ein.</p>
  </li>
</ol>

<p>Diese Massnahmen sind nicht TYPO3-spezifisch. Jede PHP-Anwendung, die hinter nginx und PHP-FPM läuft — ob WordPress, Symfony oder Laravel — profitiert von dieser Absicherung. Entscheidend ist, dass Rate Limiting allein nicht ausreicht: Wer nur Requests pro Sekunde begrenzt, übersieht das Problem gleichzeitig offener Verbindungen, die Worker blockieren.</p>

<hr />

<p>Steht Ihre Website regelmässig unter Beschuss durch Scanner oder Bots? Wir analysieren Ihre Server-Konfiguration und implementieren passende Schutzmassnahmen. Nehmen Sie über unser <a href="/kontakt/">Kontaktformular</a> unverbindlich Kontakt mit uns auf.</p>]]></content><author><name>christopher.zechendorf</name></author><summary type="html"><![CDATA[Ein einzelner Scanner legte eine TYPO3-Website lahm — nicht durch DDoS, sondern durch PHP-FPM-Worker-Erschöpfung. So haben wir den Server abgesichert.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://ext.dev/img/post/vulnerability-scanner-absicherung.webp" /><media:content medium="image" url="https://ext.dev/img/post/vulnerability-scanner-absicherung.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Wenn GeoIP versagt: Schweizer Nutzer zuverlässig erkennen</title><link href="https://ext.dev/blog/2026/04/02/geoip-schweizer-nutzer/" rel="alternate" type="text/html" title="Wenn GeoIP versagt: Schweizer Nutzer zuverlässig erkennen" /><published>2026-04-02T08:00:00+00:00</published><updated>2026-04-02T08:00:00+00:00</updated><id>https://ext.dev/blog/2026/04/02/geoip-schweizer-nutzer</id><content type="html" xml:base="https://ext.dev/blog/2026/04/02/geoip-schweizer-nutzer/"><![CDATA[<h2 id="wenn-geoip-versagt-wie-wir-schweizer-nutzer-auf-vinumeu-zuverlässig-erkennen">Wenn GeoIP versagt: Wie wir Schweizer Nutzer auf VINUM.eu zuverlässig erkennen</h2>

<p><a href="https://www.vinum.eu" target="_blank">VINUM.eu</a> betreibt mehrere Ländereditionen – CH, DE, FR und EN – und leitet Besucher beim ersten Aufruf automatisch anhand ihrer IP-Adresse auf die passende Edition weiter. Klingt einfach, funktioniert in der Praxis aber nicht immer. Besonders bei Schweizer Nutzern.</p>

<h3 id="die-herausforderung">Die Herausforderung</h3>

<p>Während eines Seminars in Zürich fiel auf, dass ein Grossteil der Teilnehmer trotz Schweizer Standort auf die deutsche Edition von VINUM weitergeleitet wurde. Die Ursache: GeoIP-Datenbanken erreichen für Schweizer IP-Adressen nur eine Trefferquote von rund 78 %.</p>

<p>Die Gründe dafür sind vielfältig:</p>

<ul>
  <li><strong>VPN-Nutzung:</strong> Viele Schweizer Unternehmen und Privatnutzer verwenden VPN-Dienste mit Endpunkten in Deutschland.</li>
  <li><strong>CGNAT (Carrier-Grade NAT):</strong> Mobilfunkanbieter teilen sich IP-Blöcke über Landesgrenzen hinweg.</li>
  <li><strong>Swisscom-IP-Blöcke:</strong> Teile der von Swisscom genutzten IP-Ranges sind in Datenbanken fälschlicherweise Deutschland zugeordnet.</li>
</ul>

<p>Das Ergebnis: Mehr als jeder fünfte Schweizer Besucher landete auf der falschen Länderseite – ein Problem für User Experience und Conversion gleichermassen.</p>

<h3 id="unsere-lösung">Unsere Lösung</h3>

<h4 id="accept-language-als-fallback-signal">Accept-Language als Fallback-Signal</h4>

<p>Browser senden bei jedem Request einen <code class="language-plaintext highlighter-rouge">Accept-Language</code>-Header mit, der die bevorzugten Sprachen und Regionen des Nutzers enthält. Schweizer Browser liefern dabei typischerweise Subtags wie <code class="language-plaintext highlighter-rouge">de-CH</code>, <code class="language-plaintext highlighter-rouge">fr-CH</code> oder <code class="language-plaintext highlighter-rouge">it-CH</code> – unabhängig davon, welche IP-Adresse der Nutzer gerade hat.</p>

<p>Wir haben in TYPO3 die bestehende <code class="language-plaintext highlighter-rouge">GeoipHelper.php</code> um eine neue Methode <code class="language-plaintext highlighter-rouge">hasSwissAcceptLanguage()</code> erweitert. Diese prüft den <code class="language-plaintext highlighter-rouge">Accept-Language</code>-Header gegen eine Konstante <code class="language-plaintext highlighter-rouge">SWISS_ACCEPT_LANGUAGE_SUBTAGS</code>, die alle relevanten Schweizer Locale-Subtags enthält:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">const</span> <span class="no">SWISS_ACCEPT_LANGUAGE_SUBTAGS</span> <span class="o">=</span> <span class="p">[</span><span class="s1">'de-CH'</span><span class="p">,</span> <span class="s1">'fr-CH'</span><span class="p">,</span> <span class="s1">'it-CH'</span><span class="p">,</span> <span class="s1">'rm-CH'</span><span class="p">];</span>
</code></pre></div></div>

<p>Die Logik greift an zwei Stellen:</p>

<ol>
  <li><strong><code class="language-plaintext highlighter-rouge">RedirectController::detectLanguageUid()</code></strong> – die initiale Weiterleitung beim ersten Seitenaufruf. Wenn GeoIP das Land als DE erkennt, der <code class="language-plaintext highlighter-rouge">Accept-Language</code>-Header aber einen Schweizer Subtag enthält, wird stattdessen auf <code class="language-plaintext highlighter-rouge">/ch/</code> weitergeleitet.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">LanguageController::popupAction()</code></strong> – das Popup zur Länderauswahl. Auch hier wird das Fallback-Signal berücksichtigt, damit der Nutzer direkt den richtigen Vorschlag sieht.</li>
</ol>

<p>Der Vorteil: Der <code class="language-plaintext highlighter-rouge">Accept-Language</code>-Header ist bei jedem Request vorhanden, verursacht keine zusätzlichen Kosten und erfordert keinen externen Dienst.</p>

<h4 id="ux-verbesserungen-bei-der-länderauswahl">UX-Verbesserungen bei der Länderauswahl</h4>

<p>Zusätzlich haben wir die manuelle Länderauswahl überarbeitet:</p>

<ul>
  <li>
    <table>
      <tbody>
        <tr>
          <td><strong>Sprechende Labels:</strong> Statt der kryptischen Kürzel “CH</td>
          <td>DE</td>
          <td>FR” stehen jetzt die ausgeschriebenen Namen “Deutschland”, “Schweiz” und “Suisse romande” in der Auswahl.</td>
        </tr>
      </tbody>
    </table>
  </li>
  <li><strong>Mobile Optimierung:</strong> Auf mobilen Geräten wurde die Länderauswahl in ein Dropdown im Header verschoben, statt sie im Footer zu verstecken.</li>
</ul>

<p>Beide Massnahmen senken die Hürde für Nutzer, die trotz aller Automatik auf der falschen Edition gelandet sind.</p>

<h3 id="das-ergebnis">Das Ergebnis</h3>

<p>Die Kombination aus GeoIP und Accept-Language-Fallback löst 60–70 % der bisherigen Fehlerkennungen bei Schweizer Nutzern auf. Konkret bedeutet das:</p>

<ul>
  <li><strong>Keine zusätzlichen Kosten</strong> – der Accept-Language-Header ist bereits in jedem HTTP-Request enthalten.</li>
  <li><strong>Keine Abhängigkeit von Drittanbietern</strong> – die Lösung läuft komplett serverseitig in TYPO3/PHP.</li>
  <li><strong>Minimaler Implementierungsaufwand</strong> – eine neue Methode, eine Konstante und zwei Aufrufe in bestehenden Controllern.</li>
</ul>

<p>Für die verbleibenden Fälle (z. B. Nutzer mit vollständig nicht-schweizerischer Browserkonfiguration hinter einem deutschen VPN) sorgt die verbesserte manuelle Länderauswahl dafür, dass der Wechsel zur richtigen Edition schnell und unkompliziert möglich ist.</p>

<h3 id="haben-sie-ein-ähnliches-problem">Haben Sie ein ähnliches Problem?</h3>

<p>GeoIP-basierte Weiterleitungen sind ein häufiges Muster bei mehrsprachigen Websites – und die Tücken liegen im Detail. Wenn Sie Probleme mit der Ländererkennung haben oder eine mehrsprachige TYPO3-Website planen, sprechen Sie uns an. Wir finden eine Lösung, die zu Ihrem Setup passt.</p>

<p><a href="/kontakt/">Kontakt aufnehmen</a></p>]]></content><author><name>christopher.zechendorf</name></author><summary type="html"><![CDATA[Wie wir die Länder­erkennung auf VINUM.eu mit Accept-Language-Headers als Fallback verbessert und die Fehlerkennungsrate bei Schweizer Nutzern deutlich gesenkt haben.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://ext.dev/img/post/geoip-schweizer-nutzer.webp" /><media:content medium="image" url="https://ext.dev/img/post/geoip-schweizer-nutzer.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">KI-gestützte Code-Qualität: Typfehler automatisch erkennen mit Claude</title><link href="https://ext.dev/blog/2026/03/27/ki-gestuetzte-typ-erkennung-claude/" rel="alternate" type="text/html" title="KI-gestützte Code-Qualität: Typfehler automatisch erkennen mit Claude" /><published>2026-03-27T12:00:00+00:00</published><updated>2026-03-27T12:00:00+00:00</updated><id>https://ext.dev/blog/2026/03/27/ki-gestuetzte-typ-erkennung-claude</id><content type="html" xml:base="https://ext.dev/blog/2026/03/27/ki-gestuetzte-typ-erkennung-claude/"><![CDATA[<h2 id="von-manueller-fehlersuche-zur-automatischen-erkennung">Von manueller Fehlersuche zur automatischen Erkennung</h2>

<h3 id="die-herausforderung">Die Herausforderung</h3>

<p>Im <a href="/blog/2026/03/27/php84-strict-types-typfehler-typo3/">vorangegangenen Beitrag</a> haben wir gezeigt, welche Typfehler beim PHP 8.4-Upgrade in TYPO3-Extensions auftreten. Die manuelle Suche nach diesen Mustern ist zeitaufwändig: <code class="language-plaintext highlighter-rouge">findByUid()</code> wird in einem typischen Projekt hunderte Male aufgerufen, und nicht jeder Aufruf ist problematisch. Klassische statische Analyse-Tools wie PHPStan stoßen hier an Grenzen — besonders bei dynamischen Framework-Konstrukten wie TypoScript-Settings, Extbase-Argumenten oder <code class="language-plaintext highlighter-rouge">$GLOBALS</code>-Zugriffen.</p>

<p>Die Frage war: Können wir die Erkennung dieser Muster automatisieren?</p>

<h3 id="unsere-lösung">Unsere Lösung</h3>

<p>Wir haben unser bestehendes <code class="language-plaintext highlighter-rouge">/techdebt</code>-Kommando in <a href="https://claude.ai/claude-code" target="_blank">Claude Code</a> um eine neue Prüfkategorie erweitert: <strong>Call-Site Type Mismatch Detection</strong>. Das Tool scannt TYPO3-Extensions gezielt nach den Mustern, die wir in der Praxis als häufigste Fehlerquellen identifiziert haben.</p>

<h4 id="was-das-tool-erkennt">Was das Tool erkennt</h4>

<p>Die Erkennung konzentriert sich auf vier Kategorien, die in der Praxis die meisten <code class="language-plaintext highlighter-rouge">TypeError</code>-Crashes verursachen:</p>

<p><strong>1. String-Argumente an <code class="language-plaintext highlighter-rouge">findByUid()</code></strong></p>

<p>Das Tool identifiziert Aufrufe, bei denen der übergebene Wert aus einer String-Quelle stammt — etwa <code class="language-plaintext highlighter-rouge">$GLOBALS</code>-Arrays, <code class="language-plaintext highlighter-rouge">getArgument()</code>, TypoScript-Settings, <code class="language-plaintext highlighter-rouge">explode()</code> oder String-Literale wie <code class="language-plaintext highlighter-rouge">'1'</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>⚠ EventController.php:1028 — findByUid('1') receives string literal
  Fix: Replace '1' with 1

⚠ EventController.php:415 — findByUid($uid) where $uid comes from getArgument()
  Fix: Add (int) cast
</code></pre></div></div>

<p><strong>2. <code class="language-plaintext highlighter-rouge">date('U')</code> für int-Spalten</strong></p>

<p><code class="language-plaintext highlighter-rouge">date('U')</code> gibt einen String zurück. Wird er in ein Datenbankfeld vom Typ <code class="language-plaintext highlighter-rouge">int</code> geschrieben, schlägt das unter <code class="language-plaintext highlighter-rouge">strict_types</code> fehl:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>⚠ EventController.php:892 — date('U') assigned to int column 'tstamp'
  Fix: Replace with time()
</code></pre></div></div>

<p><strong>3. Falsche Cast-Typen bei Settern</strong></p>

<p>Setter mit <code class="language-plaintext highlighter-rouge">bool</code>-Signatur erhalten <code class="language-plaintext highlighter-rouge">(int)</code>-Casts, oder umgekehrt:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>⚠ EventController.php:340 — setDataForwarding((int)$value) but parameter expects bool
  Fix: Change cast to (bool)
</code></pre></div></div>

<p><strong>4. Doctrine DBAL Result-Objekte</strong></p>

<p>Seit TYPO3 12 gibt <code class="language-plaintext highlighter-rouge">execute()</code> ein <code class="language-plaintext highlighter-rouge">Result</code>-Objekt zurück. Das Tool erkennt, wenn dieses Objekt ohne <code class="language-plaintext highlighter-rouge">fetchAllAssociative()</code> weitergegeben wird.</p>

<h4 id="automatische-korrektur">Automatische Korrektur</h4>

<p>Neben der Erkennung bietet das Tool auch Auto-Fix-Regeln. Für jedes gefundene Muster wird ein konkreter Fix vorgeschlagen und auf Wunsch direkt angewendet:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">findByUid('1')</code> → <code class="language-plaintext highlighter-rouge">findByUid(1)</code></li>
  <li><code class="language-plaintext highlighter-rouge">findByUid($stringVar)</code> → <code class="language-plaintext highlighter-rouge">findByUid((int)$stringVar)</code></li>
  <li><code class="language-plaintext highlighter-rouge">date('U')</code> → <code class="language-plaintext highlighter-rouge">time()</code></li>
  <li><code class="language-plaintext highlighter-rouge">(int)$value</code> bei Bool-Setter → <code class="language-plaintext highlighter-rouge">(bool)$value</code></li>
</ul>

<h3 id="das-ergebnis">Das Ergebnis</h3>

<p>Beim ersten Durchlauf auf dem betroffenen Projekt fand das Tool alle 15 Typ-Mismatches, die wir zuvor manuell identifiziert hatten — plus drei weitere, die noch nicht zu Crashes geführt hatten, aber es unter bestimmten Bedingungen getan hätten.</p>

<p>Der entscheidende Vorteil: Das Tool läuft jetzt als Teil unseres regulären Code-Quality-Checks. Neue Typfehler werden erkannt, bevor sie in die Produktion gelangen — nicht erst, wenn ein Nutzer auf den betroffenen Code-Pfad trifft.</p>

<h3 id="fazit">Fazit</h3>

<p>Statische Analyse-Tools sind unverzichtbar, aber sie decken nicht alles ab — besonders nicht in Framework-Code mit dynamischen Werten. KI-gestützte Tools wie Claude Code schließen diese Lücke, weil sie Muster kontextbezogen erkennen können, nicht nur syntaktisch.</p>

<p>Für uns ist das ein Baustein in einer größeren Entwicklung: KI nicht als Ersatz für Entwickler, sondern als Werkzeug, das die Code-Qualität systematisch verbessert und Fehler abfängt, bevor sie Schaden anrichten.</p>

<hr />

<p>Sie möchten KI-gestützte Workflows in Ihre Entwicklungsprozesse integrieren? <a href="/kontakt/">Kontaktieren Sie uns</a> — wir teilen gerne unsere Erfahrungen.</p>]]></content><author><name>christopher.zechendorf</name></author><category term="development" /><summary type="html"><![CDATA[Wie wir ein KI-gestütztes Tool gebaut haben, das Typ-Mismatches in TYPO3-Extensions automatisch erkennt und behebt — dort, wo statische Analyse an ihre Grenzen stößt.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://ext.dev/img/post/ki-gestuetzte-typ-erkennung-claude.webp" /><media:content medium="image" url="https://ext.dev/img/post/ki-gestuetzte-typ-erkennung-claude.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">PHP 8.4 und strict_types: Typfehler in TYPO3 systematisch beseitigen</title><link href="https://ext.dev/blog/2026/03/27/php84-strict-types-typfehler-typo3/" rel="alternate" type="text/html" title="PHP 8.4 und strict_types: Typfehler in TYPO3 systematisch beseitigen" /><published>2026-03-27T08:00:00+00:00</published><updated>2026-03-27T08:00:00+00:00</updated><id>https://ext.dev/blog/2026/03/27/php84-strict-types-typfehler-typo3</id><content type="html" xml:base="https://ext.dev/blog/2026/03/27/php84-strict-types-typfehler-typo3/"><![CDATA[<h2 id="wenn-php-plötzlich-streng-wird">Wenn PHP plötzlich streng wird</h2>

<h3 id="die-herausforderung">Die Herausforderung</h3>

<p>Mit dem Upgrade auf PHP 8.4 und der konsequenten Nutzung von <code class="language-plaintext highlighter-rouge">declare(strict_types=1)</code> treten in vielen TYPO3-Projekten plötzlich <code class="language-plaintext highlighter-rouge">TypeError</code>-Exceptions auf — an Stellen, die jahrelang problemlos liefen. Der Grund: PHP akzeptierte in früheren Versionen stillschweigend falsche Typen und konvertierte sie automatisch. Unter <code class="language-plaintext highlighter-rouge">strict_types</code> ist damit Schluss.</p>

<p>In einem unserer TYPO3-Projekte führte das zu Produktionsausfällen: Nutzer konnten keine Bilder mehr hochladen, Stammdaten nicht bearbeiten und Events nicht aktualisieren. Die Fehler traten erst im Live-Betrieb auf, weil die betroffenen Code-Pfade in der Entwicklungsumgebung nicht vollständig durchlaufen wurden.</p>

<h3 id="die-häufigsten-muster">Die häufigsten Muster</h3>

<p>Wir haben die Fehler analysiert und fünf wiederkehrende Muster identifiziert, die in fast jeder gewachsenen TYPO3-Extension vorkommen:</p>

<h4 id="1-findbyuid-erhält-einen-string-statt-int">1. <code class="language-plaintext highlighter-rouge">findByUid()</code> erhält einen String statt int</h4>

<p>Das häufigste Problem. Werte aus <code class="language-plaintext highlighter-rouge">$GLOBALS['TSFE']</code>, Request-Argumenten oder TypoScript-Settings sind immer Strings — <code class="language-plaintext highlighter-rouge">findByUid()</code> erwartet aber einen <code class="language-plaintext highlighter-rouge">int</code>.</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Vorher — crasht unter strict_types</span>
<span class="nv">$storage</span> <span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">storageRepository</span><span class="o">-&gt;</span><span class="nf">findByUid</span><span class="p">(</span><span class="s1">'1'</span><span class="p">);</span>
<span class="nv">$user</span> <span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">userRepository</span><span class="o">-&gt;</span><span class="nf">findByUid</span><span class="p">(</span><span class="nv">$GLOBALS</span><span class="p">[</span><span class="s1">'TSFE'</span><span class="p">]</span><span class="o">-&gt;</span><span class="n">fe_user</span><span class="o">-&gt;</span><span class="n">user</span><span class="p">[</span><span class="s1">'uid'</span><span class="p">]);</span>
<span class="nv">$event</span> <span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">eventRepository</span><span class="o">-&gt;</span><span class="nf">findByUid</span><span class="p">(</span><span class="nv">$eventUid</span><span class="p">);</span> <span class="c1">// aus explode()</span>

<span class="c1">// Nachher</span>
<span class="nv">$storage</span> <span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">storageRepository</span><span class="o">-&gt;</span><span class="nf">findByUid</span><span class="p">(</span><span class="mi">1</span><span class="p">);</span>
<span class="nv">$user</span> <span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">userRepository</span><span class="o">-&gt;</span><span class="nf">findByUid</span><span class="p">((</span><span class="n">int</span><span class="p">)</span><span class="nv">$GLOBALS</span><span class="p">[</span><span class="s1">'TSFE'</span><span class="p">]</span><span class="o">-&gt;</span><span class="n">fe_user</span><span class="o">-&gt;</span><span class="n">user</span><span class="p">[</span><span class="s1">'uid'</span><span class="p">]);</span>
<span class="nv">$event</span> <span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">eventRepository</span><span class="o">-&gt;</span><span class="nf">findByUid</span><span class="p">((</span><span class="n">int</span><span class="p">)</span><span class="nv">$eventUid</span><span class="p">);</span>
</code></pre></div></div>

<h4 id="2-dateu-statt-time">2. <code class="language-plaintext highlighter-rouge">date('U')</code> statt <code class="language-plaintext highlighter-rouge">time()</code></h4>

<p><code class="language-plaintext highlighter-rouge">date('U')</code> gibt einen Unix-Timestamp zurück — aber als String. Wird dieser Wert in eine Datenbankspalte vom Typ <code class="language-plaintext highlighter-rouge">int</code> geschrieben, knallt es.</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Vorher</span>
<span class="nv">$object</span><span class="o">-&gt;</span><span class="nf">setTstamp</span><span class="p">(</span><span class="nb">date</span><span class="p">(</span><span class="s1">'U'</span><span class="p">));</span>

<span class="c1">// Nachher</span>
<span class="nv">$object</span><span class="o">-&gt;</span><span class="nf">setTstamp</span><span class="p">(</span><span class="nb">time</span><span class="p">());</span>
</code></pre></div></div>

<h4 id="3-doctrine-dbal-execute-gibt-kein-array-mehr-zurück">3. Doctrine DBAL <code class="language-plaintext highlighter-rouge">execute()</code> gibt kein Array mehr zurück</h4>

<p>In Doctrine DBAL 3 (ab TYPO3 12) liefert <code class="language-plaintext highlighter-rouge">QueryBuilder-&gt;execute()</code> bei SELECT-Queries ein <code class="language-plaintext highlighter-rouge">Result</code>-Objekt statt eines Arrays. Wird dieses direkt an ein Fluid <code class="language-plaintext highlighter-rouge">&lt;f:form.select&gt;</code> übergeben, kommt es zum Fehler.</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Vorher</span>
<span class="nv">$regions</span> <span class="o">=</span> <span class="nv">$queryBuilder</span><span class="o">-&gt;</span><span class="nf">execute</span><span class="p">();</span>
<span class="nv">$this</span><span class="o">-&gt;</span><span class="n">view</span><span class="o">-&gt;</span><span class="nf">assign</span><span class="p">(</span><span class="s1">'regions'</span><span class="p">,</span> <span class="nv">$regions</span><span class="p">);</span>

<span class="c1">// Nachher</span>
<span class="nv">$regions</span> <span class="o">=</span> <span class="nv">$queryBuilder</span><span class="o">-&gt;</span><span class="nf">executeQuery</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">fetchAllAssociative</span><span class="p">();</span>
<span class="nv">$this</span><span class="o">-&gt;</span><span class="n">view</span><span class="o">-&gt;</span><span class="nf">assign</span><span class="p">(</span><span class="s1">'regions'</span><span class="p">,</span> <span class="nv">$regions</span><span class="p">);</span>
</code></pre></div></div>

<h4 id="4-bool-vs-int-bei-settern">4. Bool vs. Int bei Settern</h4>

<p>Setter-Methoden mit <code class="language-plaintext highlighter-rouge">bool</code>-Typdeklaration erhalten oft <code class="language-plaintext highlighter-rouge">int</code>-Werte (0 oder 1) aus der Datenbank oder Formularen.</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Vorher</span>
<span class="nv">$object</span><span class="o">-&gt;</span><span class="nf">setDataForwarding</span><span class="p">((</span><span class="n">int</span><span class="p">)</span><span class="nv">$value</span><span class="p">);</span>

<span class="c1">// Nachher</span>
<span class="nv">$object</span><span class="o">-&gt;</span><span class="nf">setDataForwarding</span><span class="p">((</span><span class="n">bool</span><span class="p">)</span><span class="nv">$value</span><span class="p">);</span>
</code></pre></div></div>

<h4 id="5-solr-queue-erwartet-int-timestamps">5. Solr Queue erwartet int-Timestamps</h4>

<p>Die EXT:solr <code class="language-plaintext highlighter-rouge">Queue::updateItem()</code> erwartet <code class="language-plaintext highlighter-rouge">int</code> als <code class="language-plaintext highlighter-rouge">$forcedChangeTime</code> — bekommt aber häufig Strings aus Datenbankfeldern oder <code class="language-plaintext highlighter-rouge">date()</code>-Aufrufen.</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Vorher</span>
<span class="nv">$queue</span><span class="o">-&gt;</span><span class="nf">updateItem</span><span class="p">(</span><span class="nv">$item</span><span class="p">,</span> <span class="nv">$indexConfig</span><span class="p">,</span> <span class="nv">$record</span><span class="p">[</span><span class="s1">'tstamp'</span><span class="p">]);</span>

<span class="c1">// Nachher</span>
<span class="nv">$queue</span><span class="o">-&gt;</span><span class="nf">updateItem</span><span class="p">(</span><span class="nv">$item</span><span class="p">,</span> <span class="nv">$indexConfig</span><span class="p">,</span> <span class="p">(</span><span class="n">int</span><span class="p">)</span><span class="nv">$record</span><span class="p">[</span><span class="s1">'tstamp'</span><span class="p">]);</span>
</code></pre></div></div>

<h3 id="das-ergebnis">Das Ergebnis</h3>

<p>Insgesamt haben wir über 15 Typ-Korrekturen in einem einzigen Controller durchgeführt — und ähnliche Muster in weiteren Klassen gefunden. Die Crashes im Live-Betrieb waren sofort behoben, und die Nutzer konnten die betroffenen Funktionen wieder normal verwenden.</p>

<h3 id="fazit">Fazit</h3>

<p>PHP 8.4 mit <code class="language-plaintext highlighter-rouge">strict_types</code> ist kein Feind — es ist ein Verbündeter. Die Typfehler waren schon immer da, nur hat PHP sie bisher stillschweigend geschluckt. Wer den Umstieg systematisch angeht und die fünf häufigsten Muster kennt, kann ein ganzes Projekt in kurzer Zeit absichern.</p>

<p>Im <a href="/blog/2026/03/27/ki-gestuetzte-typ-erkennung-claude/">nächsten Beitrag</a> zeigen wir, wie wir diesen Prozess mit einem KI-gestützten Tool automatisiert haben.</p>

<hr />

<p>Sie planen ein PHP-Upgrade oder kämpfen mit Typfehlern in Ihrem TYPO3-Projekt? <a href="/kontakt/">Sprechen Sie uns an</a> — wir unterstützen Sie gerne bei der Migration.</p>]]></content><author><name>christopher.zechendorf</name></author><category term="development" /><summary type="html"><![CDATA[PHP 8.4 deckt versteckte Typfehler auf, die in älteren Versionen still funktionierten. Wir zeigen die häufigsten Muster in TYPO3-Extensions und wie man sie behebt.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://ext.dev/img/post/php84-strict-types-typfehler-typo3.webp" /><media:content medium="image" url="https://ext.dev/img/post/php84-strict-types-typfehler-typo3.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>