Blog
Warum Symfony-Anwendungen nach Jahren der Entwicklung langsam werden
Symfony-Anwendungen werden meist durch Jahre kleiner Entscheidungen langsam: Abfragen, Listener, Serialisierung, synchrone Integrationen, große Entities und Caches ohne klare Invalidierung.
Wo eine gewachsene Symfony-Anwendung Zeit verliert
Eine Beitragsliste zeigt zunächst nur Titel, später auch Autoren, Schlagwörter und Übersetzungen. Beim Speichern kommen ein Audit-Eintrag und die Aktualisierung des Suchindex hinzu. Jede Erweiterung erfüllt einen Zweck. Zusammen verändern sie jedoch den Aufwand der ursprünglichen Operation.
Angesammelte Arbeit ist eine mögliche Erklärung für langsamere Antworten, noch keine Diagnose. Ebenso kommen ein ungünstiger Ausführungsplan, mehr Verkehr, Ressourcenlimits, Änderungen am Deployment oder eine Framework-Regression infrage. Untersucht wird deshalb die vollständige Anfrage: Was liest sie, worauf wartet sie, welche Objekte erzeugt sie und was überträgt sie?
Alle Beispiele sind hypothetisch, keine GiSoft-Benchmarks oder Berichte über Kundenvorfälle. Die PHP-Ausschnitte setzen wegen der readonly-Klassen mindestens PHP 8.2 voraus; Bibliotheken und Testwerkzeuge können höhere Anforderungen haben. Projektentitäten, Mappings, Imports, Dienstkonfiguration und Testhilfen sind ausgelassen, soweit sie nicht ausdrücklich gezeigt werden.
Die für den Nutzer relevante Operation messen
Zur Antwortzeit gehören nicht nur SQL-Abfragen. Hydrierung, Normalisierung oder Rendering, externe Aufrufe, Cache-Zugriffe und Logging müssen ebenfalls erfasst werden. Routing, Sicherheit, Sessions und Containerinitialisierung bleiben Teil der Untersuchung; Framework-Aufwand ist nicht grundsätzlich bedeutungslos. Auf Infrastrukturebene sind Anwendungsverarbeitung, das Warten auf einen freien PHP-Prozess und die Übertragung an den Client zu unterscheiden.
Der Symfony Profiler hilft bei einzelnen Anfragen in einer geschützten Entwicklungsumgebung. APM-Traces und Datenbankmonitoring liefern Produktionseinblicke, ohne den Debug-Profiler öffentlich zugänglich zu machen. Detaillierte Datensammler verursachen selbst Aufwand. Vergleiche zwischen Versionen benötigen daher gleichwertige Instrumentierung und produktionsnahe Einstellungen.
Dieses erfundene Profil zeigt mögliche Messgrößen:
GET /api/{locale}/posts 20 gelieferte Beiträge
Antwortzeit 480 ms
Datenbank 63 Abfragen / 120 ms
Hydrierung 90 ms
Serialisierung 180 ms
Externe Aufrufe 0
Spitzenspeicherbedarf 82 MB
Antwortinhalt 1,8 MBDiese Zeiten dürfen nur addiert werden, wenn die Messbereiche nachweislich überschneidungsfrei sind. Ein Serialisierungsabschnitt kann Lazy-Loading-Abfragen enthalten; parallele HTTP-Aufrufe können sich überlappen. Je nach Datensammler umfasst die Datenbankzeit unterschiedlich viel Aufwand für das Abrufen der Zeilen. Das Beispiel legt nahe, Datenzugriff und Antwortaufbau näher zu prüfen. Den tatsächlichen Engpass muss der Trace zeigen. Fünf Millisekunden weniger Initialisierung erklären den übrigen Aufwand nicht.
Repräsentativ sind auch Datenverteilung, Relationsgrößen und Berechtigungen, nicht nur viele Zeilen. Beispielbestände von 50, 5.000 und 100.000 Datensätzen können unterschiedliche Ausführungspläne sichtbar machen. Bleibt die Ergebnisseite bei 50 Einträgen, sagen vier Abfragen auf jeder Skala noch nichts über gelesene Zeilen, Sortierung, Hydrierung und Übertragung aus.
Dagegen illustrieren 52, 502 und 5.002 Abfragen für 50, 500 und 5.000 gelieferte Einträge ein mögliches N + 2-Muster. Hier wächst auch das Ergebnis; das ist ein anderer Versuch. Kalte und gefüllte Caches, realistische Nebenläufigkeit und ein vergleichbarer EntityManager-Zustand sind getrennt zu untersuchen.
Den Leseumfang begrenzen, dann die Ladestrategie wählen
Diese Verwaltungsliste ist bewusst unbegrenzt:
$posts = $postRepository->findBy(
[],
['createdAt' => 'DESC', 'id' => 'DESC'],
);
$rows = [];
foreach ($posts as $post) {
$rows[] = [
'title' => $post->translationFor($locale)->getTitle(),
'author' => $post->getAuthor()->getDisplayName(),
'tags' => $post->getTags()->map(
static fn (Tag $tag): string => $tag->getName(),
)->toArray(),
];
}Der Ausschnitt setzt voraus, dass jeder Beitrag einen Autor hat und translationFor() eine Übersetzung für die gewünschte Sprache oder den vereinbarten Rückfall liefert. Fehlende Werte brauchen eine ausdrückliche Behandlung. Tag und die gezeigten Entitätsmethoden gehören zur Beispielanwendung.
Das Lesen des Autorennamens, die Übersetzungsauswahl und das Durchlaufen der Tags können Relationen laden. Bei noch nicht initialisierten Beziehungen lautet ein vereinfachtes Modell: eine Beitragsabfrage und für jeden der drei Zugriffswege bis zu eine weitere Abfrage pro Beitrag. 1 + 3N ist aber keine Zusage. Gemeinsame Autoren in der Identity Map, bereits geladene Beziehungen, Mapping, Caches und die Übersetzungsmethode verändern das Ergebnis. Ein Relationszugriff in einer Schleife ist nicht automatisch falsch, wenn die Daten passend vorbereitet wurden.
Ein Listen-DTO macht die erwartete Ausgabe sichtbar:
final readonly class PostListItem
{
/**
* @param list<string> $tags
*/
public function __construct(
public int $id,
public string $title,
public string $slug,
public string $authorName,
public array $tags,
public \DateTimeImmutable $publishedAt,
) {
}
}Titel und Autorenname sind hier Pflichtwerte; veröffentlichte Beiträge besitzen ein unveränderliches Veröffentlichungsdatum. Die Implementierung muss diese Annahmen erfüllen. Sie kann skalare Werte projizieren oder gezielt geladene Entitäten abbilden. Ein Mapper, der jede Beziehung erst nachlädt, erzeugt das ursprüngliche Problem erneut.
Der Lesevertrag bleibt eng gefasst:
interface PostReadRepositoryInterface
{
/**
* @return list<PostListItem>
*/
public function findPublishedPage(
string $locale,
int $page,
int $limit,
): array;
}Die Signatur allein erzwingt weder Pagination noch Berechtigungen oder Sprachrückfälle. Die Implementierung muss Seitenparameter prüfen, Zugriffsregeln anwenden und das Ergebnis begrenzen. PHPStan kann list<PostListItem> und dessen Verwendung prüfen, nicht die SQL-Kosten.
DTOs sind hilfreich, wenn ein Lesevorgang nur wenige Werte braucht. Sie sind weder Pflicht noch automatisch schneller. Verwaltete Entitäten bleiben für fachliche Änderungen sinnvoll. Der GiSoft-Artikel „N+1 beginnt beim Entwurf des Datenzugriffs“ behandelt die Ladestrategien ausführlicher; hier geht es um ihren Beitrag zum gesamten Anfrageaufwand.
Nur die für diesen Zugriff benötigten Relationen laden
Dieser Repository-Ausschnitt lädt den Autor zusammen mit dem Beitrag:
/**
* @return list<Post>
*/
public function findPublishedWithAuthor(
int $limit,
): array {
if ($limit < 1 || $limit > 100) {
throw new \InvalidArgumentException(
'Das Limit muss zwischen 1 und 100 liegen.',
);
}
return $this->createQueryBuilder('post')
->addSelect('author')
->innerJoin('post.author', 'author')
->andWhere('post.published = :published')
->setParameter('published', true)
->orderBy('post.publishedAt', 'DESC')
->addOrderBy('post.id', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}addSelect('author') nimmt die verbundene Entität in die Objekthydrierung auf und macht die Verbindung zum Fetch Join. Der Inner Join lässt Beiträge ohne Autor weg; bei optionalen Autoren müssen Join und Behandlung fehlender Werte angepasst werden. Vorausgesetzt sind eine To-one-Autorenbeziehung und keine weiteren vervielfachenden Joins. Die ID als zweites Sortierkriterium sorgt bei unveränderten Daten für eine eindeutige Reihenfolge.
Ein To-one-Join vervielfacht die Zeilen der Hauptentität normalerweise nicht. Mehrere unabhängige Collections können das durchaus: Vier Übersetzungen, acht Tags, zwanzig Kommentare und drei Anhänge können für einen Beitrag 4 × 8 × 20 × 3 = 1.920 SQL-Zeilen ergeben, wenn alle Kombinationen verbunden werden. Filter und Join-Bedingungen ändern diesen Wert. Es sind keine 1.920 verschiedenen Beiträge, aber Übertragung und Zusammenführung zu eindeutigen Objekten bleiben aufwendig. DISTINCT entfernt keine Kombinationen mit unterschiedlichen Kindwerten.
Bei Collection-Fetch-Joins begrenzt ein einfaches Limit möglicherweise SQL-Zeilen statt vollständiger Hauptentitäten. Dafür braucht es einen passenden Doctrine-Paginator oder einen bewusst zweistufigen Zugriff. Paginationseinstellungen sind nicht ungeprüft auf andere Abfragen übertragbar.
Erst die Seiten-IDs, dann deren Details
Diese Methoden sind projektspezifisch, keine Doctrine-APIs:
$postIds = $this->posts->findPublishedIds(
locale: $locale,
page: $page,
limit: $limit,
);
$items = $postIds === []
? []
: $this->posts->findListItemsByIds(
ids: $postIds,
locale: $locale,
);Die erste Phase muss eindeutige IDs mit konsistenten Filtern, Berechtigungen und stabiler Sortierung auswählen. Die zweite muss diese Einschränkungen erhalten und dieselbe Reihenfolge liefern: IN (:ids) bewahrt die Eingabereihenfolge nicht. Benötigte Beziehungen sind in begrenzten Stapeln oder Projektionen zu laden, nicht mit einer Einzelabfrage pro ID. Bei einer leeren Seite entfällt der Detailabruf.
Zwei Phasen können mehr als zwei SQL-Anweisungen bedeuten, etwa für Gesamtzahlen oder mehrere Collections. Sie können die Zeilenvervielfachung verringern, verursachen aber zusätzliche Datenbankzugriffe. Sind gleichzeitige Änderungen zwischen den Phasen relevant, muss die erforderliche Snapshot- oder Transaktionskonsistenz feststehen.
Pagination, Serialisierung und Twig teilen sich ein Budget
Ein unbeschränkter Zugriff kann für seinen ursprünglichen Bildschirm zu groß werden:
$repository->findAll();Eine hypothetische Tabelle, die von 200 auf 400.000 Zeilen wächst, verdeutlicht das Risiko. Für Objektallokation und Rendering zählt die gelieferte Menge, nicht nur die Tabellengröße. Pagination gehört zum serverseitigen Lesevertrag.
Dieses Parameter-DTO verwendet eine projektspezifische Obergrenze von 100:
final readonly class PageRequest
{
public function __construct(
public int $page = 1,
public int $limit = 25,
) {
if ($page < 1) {
throw new \InvalidArgumentException(
'Die Seitenzahl muss größer als null sein.',
);
}
if ($limit < 1 || $limit > 100) {
throw new \InvalidArgumentException(
'Das Limit muss zwischen 1 und 100 liegen.',
);
}
}
}HTTP-Parameter müssen vor der Konstruktion geparst und validiert werden. PHP-Eigenschaftstypen ersetzen keine vollständige Prüfung roher URL-Parameter. Das Limit gehört ins SQL, zusammen mit einer eindeutigen Sortierung. Hohe Offsets und Gesamtzählungen haben eigene Kosten. Keyset-Pagination kann fortlaufendes Blättern erleichtern, bietet aber nicht unmittelbar den Sprung zu beliebigen Seitennummern.
Symfony Serializer kann abhängig von Normalizern, Gruppen und Kontext Getter lesen und Beziehungen durchlaufen. Dadurch können weitere Abfragen, große Antworten oder unbeabsichtigt sichtbare Felder entstehen. Das gilt nicht für jede Entity-Kodierung: Ein einfaches json_encode() durchläuft nicht automatisch alle privaten Beziehungen. Gruppen wählen Felder aus; sie planen weder das Laden noch ersetzen sie die Autorisierung.
Für öffentliche Antworten kann eine ausdrückliche Darstellung helfen:
final readonly class PublicPostDto
{
/**
* @param list<string> $tags
*/
public function __construct(
public string $title,
public string $slug,
public string $excerpt,
public array $tags,
) {
}
}Es dürfen nur Felder gefüllt werden, die der Aufrufer sehen darf; auch das Mapping muss begrenzt bleiben. Ein DTO aus einem übergroßen Objektgraphen gewinnt die bereits verbrauchte Ladezeit nicht zurück.
Twig kann denselben Fehler im Lesepfad sichtbar machen:
{% for page in pages %}
<h2>{{ page.translationFor(app.request.locale).title }}</h2>
<span>
{{ page.parent.translationFor(app.request.locale).title }}
</span>
{% for feature in page.features %}
{{ feature.translationFor(app.request.locale).name }}
{% endfor %}
{% endfor %}Dieser Ausschnitt setzt eine übergeordnete Seite und vorhandene Übersetzungen voraus. Eine echte Ansicht muss fehlende Werte gemäß ihrer Darstellungsregeln behandeln. Zugriffsmethoden können ungeladene Beziehungen initialisieren, aber nicht jeder Eigenschaftszugriff erzeugt SQL. Bewusst vorbereitete Entitäten können genügen; ein PageAdminRow-Ansichtsmodell ist eine Alternative. Eine durchgängige Abfragemessung muss Rendering oder Normalisierung einschließen, nicht nur den Repository-Aufruf.
Synchrone Arbeit außerhalb des Controllers finden
Der übliche EventDispatcher von Symfony ruft Listener synchron auf. Ein Listener kann Arbeit an eine Warteschlange übergeben; der Ereignisname allein sagt jedoch nichts über asynchrone Ausführung oder dauerhafte Zustellung. Audit-Einträge, Übersetzungen, Cache-Invalidierung, Indexierung und Webhooks können deshalb Anfragen verlängern, ohne im Controller aufzufallen.
Doctrine-Lifecycle-Ereignisse haben eigene Regeln. prePersist, postUpdate und postLoad laufen nicht sämtlich bei jedem Flush. Beispielsweise erfolgt postUpdate bei entsprechenden ORM-Aktualisierungen innerhalb von flush(), vor dem Commit; es ist keine After-Commit-Benachrichtigung. Auch postFlush beweist nicht, dass eine umschließende Transaktion bestätigt wurde. Vor Änderungen an Hooks ist die eingesetzte ORM-Version zu prüfen. Ein erneutes flush() aus einem durch Flush ausgelösten Listener ist keine sichere allgemeine Persistenztechnik.
Persistenzbezogene Hooks sollten möglichst klein bleiben. Aufwendige Koordination wird besser in einem profilierbaren und testbaren Dienst oder Handler sichtbar. Das ist eine Entscheidung über Zuständigkeiten, kein Anlass für zusätzliche Schichten in jeder Operation.
Auch die Sendungsverfolgung kann erhebliche Wartezeit verbergen:
$rows = [];
foreach ($orders as $order) {
$tracking = $shippingClient->getTracking(
$order->getTrackingNumber(),
);
$rows[] = $this->mapper->map($order, $tracking);
}getTracking() und der Mapper sind illustrative Projektverträge, keine verifizierten Methoden eines Versanddienstleister-SDKs. Bei blockierenden Aufrufen ergeben 50 Bestellungen mit angenommenen 100 ms pro Aufruf etwa fünf Sekunden, noch vor weiterer Verarbeitung. Ein verzögerter oder nebenläufiger Client muss anders gemessen werden.
Je nach Anbieter und Aktualitätsbedarf kommen ein dokumentierter Sammelabruf, ein regelmäßig synchronisierter lokaler Stand, Caching, Abruf bei Bedarf oder begrenzte Parallelität infrage. Eine Batch-API darf nicht vorausgesetzt werden. Parallelität und Wartezeiten brauchen Grenzen; außerdem sind Anbieterkontingente und die Anzeige bei nicht verfügbaren Trackingdaten festzulegen.
Containerzugriffe können verschleiern, welche Abhängigkeit Arbeit auslöst:
$service = $container->get('some_service');Konstruktorinjektion macht diese Abhängigkeiten leichter prüfbar:
final class ProductImportService
{
public function __construct(
private readonly ProductRepositoryInterface $products,
private readonly ProductMapperInterface $mapper,
private readonly ImportMetricsInterface $metrics,
) {
}
}Gezeigt ist eine Deklaration mit Projektinterfaces. Injektion ist nicht grundsätzlich schneller als ein Abruf aus dem Symfony-Container. Eine gemeinsam genutzte Dienstinstanz wird dabei auch nicht zwangsläufig neu konstruiert. Der Nutzen liegt hier in sichtbarem Datenzugriff, Mapping und Messung des Imports.
Arbeit auslagern, ohne sie zu verlieren
Eine Warteschlange verschiebt Ort und Zeitpunkt der Kosten. Sie entfernt weder Datenbanklast noch Anbietergebühren oder CPU-Arbeit. Indexierung, Berichte, Benachrichtigungen und Medienkonvertierung eignen sich für den Hintergrund, wenn das Produkt eine spätere Fertigstellung zulässt. Berechtigungen und Voraussetzungen einer Erfolgsmeldung müssen weiterhin an der richtigen Entscheidungsstelle geprüft werden.
Eine Transactional Outbox ist eine Möglichkeit, fachliche Änderung und Folgeauftrag gemeinsam dauerhaft festzuhalten:
HTTP-Anfrage: Eingaben und Rechte prüfen
Lokale Transaktion
Fachlichen Zustand speichern
Outbox-Eintrag speichern
COMMIT
Antwort kann zurückgegeben werden
Publisher kann bestätigte Einträge versenden
-> Transport -> Worker
begrenzte Wiederholungen / DuplikatschutzBeide Einträge müssen an derselben geeigneten Datenbanktransaktion teilnehmen. Nach dem Commit besteht keine zwingende Reihenfolge zwischen Antwortübertragung und Veröffentlichung. Publisher und Empfänger können Arbeit wiederholen; die Outbox bedeutet daher keine exakt einmalige Ausführung. Neben HTTP-Latenz sind Veröffentlichungsverzug, Wartezeit, Fehler und Worker-Kapazität zu überwachen.
Ein unabhängiger Transport kann beim Versand vor Commit die Nachricht früher sichtbar machen als ihre Daten. Eine in dieselbe Transaktion eingebundene Transport-Schreiboperation verhält sich anders. Ein nur im Speicher vorgemerkter Versand nach Commit löst das Reihenfolgeproblem, lässt aber eine Ausfalllücke vor der Veröffentlichung. Der GiSoft-Leitfaden „Die Bestellung ist bearbeitet. Warum kommt die Nachricht zurück?“ erläutert Wiederholungen, Nebenläufigkeit und den Abgleich externer Wirkungen; diese Anleitung wird hier nicht wiederholt.
Logging und Sessions können Anfragen warten lassen
Große Logkontexte erhöhen Speicher- und Verarbeitungsaufwand:
$this->logger->info('Produkt importiert', [
'product' => $product,
'request' => $request->request->all(),
'response' => $providerResponse,
]);Ob eine übergebene Entität normalisiert wird oder Lazy Loading auslöst, hängt von Formattern, Prozessoren und Objektverhalten ab. Automatisch geschieht das nicht. Unabhängig davon sind vollständige Anfrage- und Anbieterinhalte wegen Umfang und Vertraulichkeit schlechte Standardwerte.
Ein begrenzter Eintrag lässt sich besser auswerten:
$this->logger->info('Produktimport abgeschlossen', [
'productId' => $product->getId(),
'provider' => $providerName,
'durationMs' => $durationMs,
'status' => 'completed',
]);Dauer, Anbieterbezeichnung und IDs müssen gezielt erfasst werden. Werte sollten begrenzt bleiben; Zugangsdaten, Tokens, sensible Zahlungsfelder und unnötige personenbezogene Daten gehören nicht hinein. Sampling und Logstufen sollen nützliche Fehlerhinweise erhalten, statt sämtliche Sichtbarkeit abzuschalten.
Sessionkonflikte sind davon zu trennen. Sperrt der konfigurierte Handler eine Session, können parallele Anfragen mit derselben Session auf die Freigabe durch eine langsame Anfrage warten. Andere Handler oder Einstellungen verhalten sich anders. Sessions sollten nicht unnötig gestartet werden. Soweit unterstützt, lassen sie sich vor langer sessionunabhängiger Arbeit speichern und schließen, jedoch erst nach Prüfung späterer Schreibzugriffe, Authentifizierung und CSRF-Verhalten. Ein pauschaler Wechsel zu zustandsloser Sicherheit ist keine ungeprüft anzuwendende Optimierung.
Caching braucht Aktualitätsregeln statt eines Ausfalldiagramms
Dies sind Schlüsselvorlagen, keine fertigen Schlüssel und kein vollständiges Berechtigungsmodell:
settings.public.{tenant}.{locale}
menu.{tenant}.{category}.{locale}.{audience}
page.{tenant}.{pageId}.{locale}.{audience}
post.list.{tenant}.{locale}.{page}.{limit}.{variant}Platzhalter müssen zu gültigen Backend-Schlüsseln aufgelöst werden. audience muss alle relevanten Sichtbarkeitsregeln erfassen; eine Rolle bildet individuelle Rechte möglicherweise nicht ab. variant steht für eine kanonische Darstellung oder einen Hash von Filtern, Sortierung und Sichtbarkeitskontext. Weitere ergebnisrelevante Eingaben gehören ebenfalls in den Schlüssel, andernfalls darf der Eintrag nicht geteilt werden. Eine Anwendung mit nur einem Mandanten und wirklich öffentlichen Daten braucht weniger Dimensionen.
Nach erfolgreicher fachlicher Transaktion werden betroffene Seiten, Übersetzungen und Menüs invalidiert, mit einem Wiederherstellungsweg für fehlgeschlagene Invalidierungen. Eine TTL begrenzt die Lebensdauer eines Eintrags und muss zur erlaubten Veraltung passen; eine ausgefallene Invalidierung wird dadurch nicht folgenlos. Bei hohen Aktualitätsanforderungen sind Rennen zwischen Neuaufbau und Invalidierung zu behandeln, etwa durch versionierte Schlüssel. Breites Leeren kann viele teure Neuaufbauten auslösen. Das Zusammenfassen gleichzeitiger Neuaufbauanforderungen oder ein unterstützter Stampede-Schutz kann helfen.
Explizite Cachedaten sind oft leichter kontrollierbar als beliebige ORM-Objektgraphen:
final readonly class PublicSettingsDto
{
public function __construct(
public string $companyName,
public string $slogan,
public string $phone,
) {
}
}PHP-Entitäten lassen sich grundsätzlich cachen. Ihre Wiederherstellung aus einem Anwendungscache stellt jedoch nicht die ursprüngliche Verwaltung durch einen EntityManager wieder her. Proxies, unvollständige Beziehungen und Deployment-Kompatibilität benötigen Aufmerksamkeit. Doctrines Ergebnis- oder Second-Level-Cache hat andere Regeln als das Ablegen beliebiger Entitäten im Anwendungscache.
Redis, Memcached und Dateisystemspeicher sind Alternativen, keine automatische Ausfallkette. Ein gemeinsames Interface legt noch keine Umschaltsemantik fest. Symfonys ChainAdapter ist ein konfigurierter mehrstufiger Cache, keine allgemeine Garantie transparenter Ausfallsicherheit. Jeder Ersatzweg muss TTL, Invalidierung zwischen Knoten, veraltete Werte, Berechtigungen und Kapazität berücksichtigen. Abhängig von den Daten können ein begrenzter Direktabruf, ein ausdrücklich erlaubter älterer Wert oder ein kontrollierter Fehler geeigneter sein.
Neben der Trefferquote zählen Zugriffslatenz, Neuaufbauzeit, Datenumfang, Backend-Fehler, Invalidierung und Aktualität. Eine hypothetische Trefferquote von 95 Prozent ist mit sehr langsamen Fehlzugriffen oder veralteten Antworten vereinbar.
Formulare, Übersetzungen und Medien gezielt prüfen
Große EntityType-Auswahllisten, verschachtelte Collections, Formular-Listener und Validierung können weit mehr Daten laden, als die sichtbare Oberfläche vermuten lässt. Serverseitige Filter, später geladene abhängige Optionen oder Autovervollständigung helfen gegebenenfalls; nicht jedes Formular benötigt eine neue API. Übermittelte IDs müssen weiterhin auf Existenz und Zugriffsrechte geprüft werden.
Auch die wiederholte Übersetzungsauswahl kann Arbeit verbergen:
$page->translationFor($locale);
$feature->translationFor($locale);
$category->translationFor($locale);Das sind Projektmethoden, keine allgemeine Symfony-Übersetzungs-API. Sie können eine geladene Collection durchsuchen, diese initialisieren oder Rückfallregeln anwenden. Eine Leseabfrage kann die gewünschte Sprache direkt auswählen. Dieses illustrative SQL setzt ein eigenes Schema mit einer Übersetzung je (page_id, locale), numerischen Veröffentlichungskennzeichen und als Ganzzahlen gebundenen Seitenparametern voraus:
SELECT
page.id,
translation.title,
translation.slug
FROM pages__page page
INNER JOIN pages__page_translation translation
ON translation.page_id = page.id
AND translation.locale = :locale
WHERE page.published = 1
ORDER BY page.sequence ASC, page.id ASC
LIMIT :limit OFFSET :offset;Der Inner Join lässt Seiten ohne diese Sprache weg. Sollen sie erhalten bleiben oder eine Ersatzsprache verwenden, ist ein anderer Entwurf nötig, etwa passende Left Joins oder eine Auflösung im Lesedienst. Mandanten- und Berechtigungsfilter sind gegebenenfalls zu ergänzen. Indizes ergeben sich aus Ausführungsplan und Last, nicht aus den Tabellennamen im Beispiel.
Bei Medien sind langsame PHP-Verarbeitung und langsame Browserdownloads zu unterscheiden. Übergroße Originale, wiederholte Dateiprüfungen und ungecachte Konvertierung brauchen unterschiedliche Korrekturen. Uploads müssen geprüft, zulässige Ausgabegrößen begrenzt und erzeugte Dateien wiederverwendet werden. Die Erzeugung kann kontrolliert beim Upload oder begründet asynchron erfolgen; ein günstiges, gecachtes Vorschaubild bei Bedarf kann ebenfalls passen. HTTP-Caching und an das Endgerät angepasste Formate verbessern die Auslieferung gegebenenfalls ohne Backend-Umbau.
Erkenntnisse in Budgets und Regressionstests übersetzen
Prioritäten ergeben sich aus Häufigkeit, Latenz, Ressourcenbelastung und fachlicher Bedeutung. Ein hypothetischer Endpunkt mit 20 Prozent des Verkehrs, P95 von 1,8 s, 74 Abfragen, 140 MB Spitzenspeicher und 4,2 MB Antwortgröße wäre untersuchenswert. Das sind illustrative Werte, keine gemeinsam beobachteten GiSoft-Messungen. Ein seltener Bericht kann dennoch wichtiger sein, wenn er einen kritischen Ablauf blockiert.
Dieses YAML beschreibt mögliche Projektbudgets. Symfony setzt sie nicht automatisch um:
performance_budget:
public_page:
max_application_queries: 8
max_response_bytes: 300000
target_p95_ms: 400
max_records_per_page: 50
admin_listing:
max_application_queries: 12
max_records_per_page: 100
target_p95_ms: 800Grenzen müssen aus Anforderungen und gemessenen Ausgangswerten einschließlich Datenmenge und Lastbedingungen entstehen. Abfrage- und Inhaltsgrößen lassen sich im CI prüfen; Perzentilziele brauchen einen repräsentativen Beobachtungszeitraum und eine passende Last. Weder vier Abfragen noch 300.000 Bytes sind universelle Grenzwerte.
Die folgenden Pest-Tests verwenden die projektspezifischen Hilfen createPublishedPosts(), postQuery() und startApplicationQueryCollection(). Vor dem Zählen müssen Testdaten geschrieben, der EntityManager kontrolliert oder geleert und der gewünschte Cachezustand hergestellt werden. Der Dienst wird vor dem Datensammler aufgelöst; zwischen isolierten Tests wird die Sammlung beendet oder zurückgesetzt. Erfasst werden muss SQL der relevanten Verbindung einschließlich Datenmapping, nicht das Anlegen der Testdaten oder der Frameworkstart.
Zunächst enthält die Seite alle 50 vorbereiteten Beiträge:
it(
'hält das Abfragebudget bei 50 Beiträgen ein',
function (): void {
$this->createPublishedPosts(count: 50);
// Testdaten sind gespeichert; ORM und Cache sind kontrolliert.
$query = $this->postQuery();
$collector = $this->startApplicationQueryCollection();
$result = $query->findPublishedPage(
locale: 'en',
page: 1,
limit: 50,
);
expect($result)->toHaveCount(50)
->and($collector->queryCount())->toBeLessThanOrEqual(4);
},
);Danach enthält die Datenbank 500 Beiträge, während die Seite bei 50 bleibt:
it(
'hält das Seitenbudget bei 500 Beiträgen ein',
function (): void {
$this->createPublishedPosts(count: 500);
// Testdaten sind gespeichert; ORM und Cache sind kontrolliert.
$query = $this->postQuery();
$collector = $this->startApplicationQueryCollection();
$result = $query->findPublishedPage(
locale: 'en',
page: 1,
limit: 50,
);
expect($result)->toHaveCount(50)
->and($collector->queryCount())->toBeLessThanOrEqual(4);
},
);Beide Tests prüfen dieselbe Obergrenze und Ergebnisgröße. Sie beweisen weder identische Abfragezahlen noch konstante Laufzeit oder Leistung unter Nebenläufigkeit. Repräsentative Testdaten benötigen Autoren, Übersetzungen und Tags für die untersuchten Ladepfade. Separate Benchmarks können Seitenumfang, Relationsgrößen und gleichzeitige Last variieren. Mikrosekunden-Assertions sind für gewöhnliches CI ungeeignet.
Ein Antwortgrößentest schützt eine andere Grenze:
it(
'begrenzt die Größe der Beitragsantwort',
function (): void {
$client = static::createClient();
// Repräsentative Testdaten und nötige Zugriffsrechte vorbereiten.
$client->request(
'GET',
'/api/en/posts?page=1&limit=25',
);
self::assertResponseIsSuccessful();
$content = $client->getResponse()->getContent();
expect($content)->toBeString();
expect(strlen($content))->toBeLessThan(300_000);
},
);Vorausgesetzt sind Pest mit einer Symfony-WebTestCase-Basisklasse, repräsentative veröffentlichte Testdaten, passende Zugriffsrechte und die dargestellte Route. Der Inhalt ist eine Zeichenkette, keine Streaming-Antwort. strlen() zählt Bytes der Testantwort, nicht unbedingt die komprimiert übertragenen Netzwerkbytes. Ungültige Seiten, überhöhte Limits, leere Ergebnisse und stabile Sortierung benötigen weitere Tests; dieser Ausschnitt prüft sie nicht.
Statische Analyse, Review und Produktionsdaten
Der bereits gezeigte Rückgabetyp list<PostListItem> informiert PHPStan über Elemente und Verwendung des Ergebnisses. Je nach Konfiguration und Erweiterungen erkennt die Analyse unvereinbare Typen, unsichere Zugriffe auf nullable Werte und inkonsistente Strukturen. Sie misst kein N+1 und verbietet Repository-Abhängigkeiten oder Entity-Serialisierung nicht automatisch. Unsicherheit durch mixed zu ersetzen entfernt hilfreiche Informationen.
Architekturtests können konkrete vereinbarte Einschränkungen festhalten:
arch('API-Controller verwenden keinen EntityManager')
->expect('App\Api\Controller')
->not->toUse('Doctrine\ORM\EntityManagerInterface');
arch('der Kern hängt nicht vom externen SDK ab')
->expect('App\Core')
->not->toUse('Vendor\ExternalSdk');Die Beispiele verwenden Pests Architektur-API; Vendor\ExternalSdk ist ein Platzhalter. Werkzeugversionen, Namespaces und die tatsächlich erfassten Klassen sind zu prüfen. Die Regeln erkennen ausgewählte Abhängigkeiten, nicht jeden indirekten SQL-Aufruf, Rückgabewert oder Laufzeitengpass.
Für menschliche und KI-gestützte Änderungen gilt dieselbe Prüfung: tatsächliches Repository-Verhalten, erwartete Datenmenge, Relationszugriffe, Pagination, externe Aufrufe und Cache-Invalidierung. Gefragt sind gezielte Nachweise, keine erfundene Schätzung der Abfragezahl. Saubere Syntax und DTOs belegen keine vorhersehbaren Kosten; KI-generierter Code ist umgekehrt nicht grundsätzlich langsam.
In Produktion sind Anfrageraten und P50/P95/P99 nach Routenvorlage, Datenbankzeiten und Abfragesignaturen, externe Latenzen, Antwortumfang, Speicher und Fehler zu beobachten. Hydrierung lässt sich nur mit passender Instrumentierung separat erfassen. Bei Hintergrundarbeit kommen Wartezeit und Verarbeitungsdauer hinzu.
Labels sollten begrenzte Wertebereiche haben, etwa Operationstypen und kontrollierte Release-Kennungen. Mandanten-IDs, rohe URLs, Operations-IDs und Nutzerwerte können hochkardinale Metriken erzeugen. Benötigte Details gehören eher in zugriffsgeschützte, stichprobenartig erfasste Traces als unterschiedslos in Metriklabels.
Ein Vergleichsdatensatz könnte so aussehen; dies ist ein konzeptionelles Schema, keine vorgeschlagene Migration:
performance_snapshot
id, operation, application_version, captured_at
dataset_record_count, record_count, page_size
query_count, database_duration_ms
hydration_duration_ms, external_duration_ms
serialization_duration_ms, total_duration_ms
peak_memory_bytes, response_bytes
cache_state, measurement_scoperecord_count bezeichnet hier die gelieferten Einträge, getrennt vom gesamten Datenbestand. Eine APM-Plattform kann dieselben Informationen speichern. Verglichen werden gleichwertige Hardware, Last, Instrumentierung und Cachezustände; enthaltene Zeitabschnitte dürfen nicht doppelt gezählt und Einzelproben nicht mit Perzentilen verwechselt werden.
Eine praktische Untersuchung endet mit einer begrenzten Änderung, einer Regressionsprüfung, vergleichbaren Vorher-Nachher-Profilen und Beobachtung nach dem Release. Globales Eager Loading, flächendeckendes Caching, statische Hilfsfunktionen oder ein Rewrite ersetzen die Ursachenanalyse nicht. Ziel sind vorhersehbare Kosten und ein Entwicklungsprozess, der Regressionen erkennt, bevor sie zum Alltag gehören.
Technische Referenzen
- Symfony Profiler — https://symfony.com/doc/7.4/profiler.html
- Doctrine-Pagination — https://www.doctrine-project.org/projects/doctrine-orm/en/3.7/tutorials/pagination.html
- Doctrine-Lifecycle-Ereignisse — https://www.doctrine-project.org/projects/doctrine-orm/en/3.7/reference/events.html
- Symfony-Cache und Cacheketten — https://symfony.com/doc/current/cache.html
- Symfony-Sessions — https://symfony.com/doc/7.4/session.html
- Pest-Architekturtests — https://pestphp.com/docs/arch-testing
- PHPDoc-Verträge in PHPStan — https://phpstan.org/writing-php-code/phpdoc-types
