Blog
N+1-Abfrageprobleme in Doctrine erkennen und beheben
Ein N+1-Problem kann eine harmlose Doctrine-Schleife in Hunderte Datenbankabfragen verwandeln. Dieser Leitfaden zeigt, wie man es erkennt, reproduziert und behebt, ohne übergroße JOINs oder unnötige Hydrierung.
N+1 beginnt beim Entwurf des Datenzugriffs
Eine Liste kann die richtigen Inhalte anzeigen und trotzdem viel zu viele Datenbankabfragen ausführen. N+1 bezeichnet einen typischen Ablauf: Eine Abfrage lädt die Datensätze, anschließend löst die Verarbeitung jedes Datensatzes eine weitere Abfrage für zugehörige Daten aus. Die Kurzform 1 + N beschreibt das Muster, nicht die genaue Abfragezahl jeder Anwendung.
Bei Doctrine passen dann häufig die Repository-Abfrage und der Datenbedarf des aufrufenden Codes nicht zusammen. Die zusätzlichen Abfragen entstehen möglicherweise erst im Service, Mapper, Twig-Template oder Serializer. Lazy Loading ist durchaus nützlich. Sein Aufwand bleibt allerdings leicht unbemerkt, wenn niemand den gesamten Leseweg betrachtet.
Beiträge mit Autoren und Übersetzungen
Als Beispiel dient eine Liste veröffentlichter Beiträge. Jeder Beitrag hat genau einen erforderlichen Autor und eine Sammlung von Übersetzungen. Der Entity-Ausschnitt zeigt nur diese Beziehungen:
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
class Post
{
#[ORM\ManyToOne(targetEntity: User::class, fetch: 'LAZY')]
#[ORM\JoinColumn(nullable: false)]
private User $author;
/** @var Collection<int, PostTranslation> */
#[ORM\OneToMany(
mappedBy: 'post',
targetEntity: PostTranslation::class,
fetch: 'LAZY',
)]
private Collection $translations;
public function __construct(User $author)
{
$this->author = $author;
$this->translations = new ArrayCollection();
}
}Das Entity-Attribut, der Primärschlüssel, weitere Felder, Zugriffsmethoden und das Mapping der besitzenden Seite PostTranslation::$post sind nicht dargestellt. Der Konstruktor initialisiert die Collection für neue Objekte. Post ist nicht final, damit das Beispiel auch zu Konfigurationen mit generierten Proxy-Unterklassen passt; für native Lazy Objects gelten andere Einschränkungen.
Wir nehmen an, dass translationFor($locale) die Übersetzungs-Collection im Speicher durchsucht und eine Übersetzung oder null zurückgibt. Dieser bewusst einfache Lesezugriff setzt voraus, dass für jeden ausgewählten Beitrag die gewünschte Übersetzung existiert:
$posts = $postRepository->findBy(
['published' => true],
['publishedAt' => 'DESC', 'id' => 'DESC'],
);
$result = [];
foreach ($posts as $post) {
$result[] = [
'title' => $post->translationFor($locale)->getTitle(),
'author' => $post->getAuthor()->getDisplayName(),
];
}Der Zugriff auf den Anzeigenamen eines noch nicht geladenen Autors kann dessen Initialisierung auslösen. Das Durchsuchen einer ungeladenen Collection kann die Übersetzungen dieses Beitrags nachladen. Für den folgenden Vergleich gelten feste Annahmen: ein frischer EntityManager, unterschiedliche Autoren je Beitrag, keine zwischengespeicherten Ergebnisse und je eine Abfrage pro solchem Beziehungszugriff.
Beispielhafte SQL-Abfragezahlen, keine Messwerte
Beiträge Liste Autoren Übersetzungen Gesamt
5 1 5 5 11
50 1 50 50 101
500 1 500 500 1001So entsteht das Beispiel „ungefähr 101 Abfragen für 50 Beiträge“. Es ist keine allgemeine Vorhersage. Doctrine kann gemeinsame Autoren über die Identity Map wiederverwenden, die verwaltete Objekte anhand ihrer Identität innerhalb eines EntityManagers zuordnet. Bereits geladene Beziehungen, Mapping, Ladeplan sowie Ergebnis- oder Second-Level-Cache beeinflussen die Zahl ebenfalls. Der Metadaten- und der DQL-Abfragecache speichern dagegen nicht die zurückgegebenen Geschäftsdaten.
Die Schleife setzt außerdem vorhandene Übersetzungen voraus und lädt alle veröffentlichten Beiträge. Das sind Vereinfachungen für die Diagnose, kein fertiger Vertrag für einen öffentlichen Listenendpunkt.
Die wiederholte Arbeit nachweisen
Ein sinnvoller Einstieg ist eine repräsentative Anfrage im Symfony Profiler: Wie viele Doctrine-Abfragen laufen, wie viel Datenbankzeit entsteht insgesamt, und welche SQL-Strukturen wiederholen sich? Ein SQL-Fingerprint fasst Anweisungen gleicher Struktur mit unterschiedlichen Parameterwerten zusammen. Ein verfügbarer Aufrufstack hilft, die wiederholte Abfrage einer Zugriffsmethode zuzuordnen.
Wenige Entwicklungsdaten, geringe Datenbanklatenz und noch verwaltete Objekte aus der Testvorbereitung können N+1 verdecken. Vergleiche beispielsweise 5, 50 und 500 Datensätze innerhalb des vorgesehenen Lastbereichs, jeweils mit kontrolliertem EntityManager- und Cache-Zustand. Entscheidend sind zusätzliche Abfragen pro Element. Eine absolut konstante Zahl ist nicht für jede Verarbeitung sinnvoll: Beim Laden in Blöcken kann an jeder Blockgrenze eine weitere Abfrage hinzukommen.
Die Protokollierung muss zur installierten DBAL- und DoctrineBundle-Version passen. Moderne DBAL-Versionen bieten dafür Middleware; ältere Anleitungen mit SQLLogger lassen sich nicht auf DBAL 4 übertragen. Ein Test-Collector sollte die untersuchte Operation erfassen, nicht das Anlegen der Testdaten oder den allgemeinen Framework-Start.
Verfolge auch den Aufruf. Diese Zugriffe können Datenbankarbeit auslösen, wenn die betreffenden Objekte oder Collections noch nicht geladen sind:
$post->getAuthor()->getDisplayName();
$post->getTranslations()->first();
count($post->getComments());getAuthor() kann zunächst nur einen Proxy zurückgeben. Erst das Lesen eines Feldes außerhalb des Identifikators initialisiert ihn üblicherweise. Bei normalen Lazy Collections können first() und count() die Collection laden. getComments() steht hier für eine zusätzliche Beispielbeziehung, die im Entity-Ausschnitt fehlt. Mapper, Debugger oder die Formatierung eines Protokolleintrags können dieselben Zugriffe ausführen.
Drei passende Ladewege für die Liste
Fetch Join: Entities mit gezielt geladenen Beziehungen
Diese Repository-Methode liefert weiterhin Entities, lädt Autoren und Übersetzungen aber gleich mit. Repository-Einrichtung und Imports sind ausgelassen:
/**
* @return list<Post>
*/
public function findPublishedWithAuthorAndTranslations(): array
{
return $this->createQueryBuilder('post')
->addSelect('author')
->addSelect('translation')
->innerJoin('post.author', 'author')
->leftJoin('post.translations', 'translation')
->andWhere('post.published = :published')
->setParameter('published', true)
->orderBy('post.publishedAt', 'DESC')
->addOrderBy('post.id', 'DESC')
->getQuery()
->getResult();
}Bei der Objekthydrierung werden diese Joins durch die Auswahl der Aliase mit addSelect() zu Fetch Joins: Doctrine befüllt die Beziehungen beim Aufbau des Ergebnisses. Ein Join, der lediglich filtert, lädt die Beziehung nicht automatisch. Der innere Join auf den Autor passt zur Pflichtbeziehung. Bei optionalen Autoren müssten Join und aufrufender Code angepasst werden. Der linke Join auf Übersetzungen erhält Beiträge ohne Übersetzung; dafür braucht der Verbraucher weiterhin eine festgelegte Behandlung.
Die Methode hat bewusst kein Seitenlimit und lädt sämtliche Übersetzungen der passenden Beiträge. Sie eignet sich für eine bekannte, kleine Ergebnismenge oder als Ausgangspunkt für die später erläuterte Seitenauswahl, nicht für eine unbegrenzte öffentliche Liste. Die Sortierung nach publishedAt und anschließend id legt eine eindeutige Beitragsreihenfolge fest, jedoch keine Reihenfolge innerhalb der Übersetzungs-Collections.
Ein Filter auf nur eine Sprache würde bei diesem Entity-Fetch-Join lediglich die passende Teilmenge als geladene Collection bereitstellen. Code, der alle Übersetzungen erwartet, könnte sie irrtümlich für vollständig halten. Für eine sprachabhängige Liste ist eine Projektion oft eindeutiger.
DTO-Projektion: ein Datenvertrag für die Liste
Benötigt die Ansicht vier Werte, muss sie keinen veränderbaren Entity-Graphen erhalten. Diese vollständige DTO-Deklaration setzt PHP 8.2 oder neuer voraus:
namespace App\ReadModel;
final readonly class PostListItem
{
public function __construct(
public int $id,
public string $title,
public string $authorName,
public \DateTimeImmutable $publishedAt,
) {
}
}Im Repository wird App\ReadModel\PostListItem importiert. Die Argumente des DQL-Konstruktorausdrucks stehen in derselben Reihenfolge wie die Konstruktorparameter:
/**
* @return list<PostListItem>
*/
public function findPublishedList(
string $locale,
int $limit,
): array {
if ($limit < 1) {
throw new \InvalidArgumentException('limit >= 1');
}
return $this->createQueryBuilder('post')
->select(sprintf(
'NEW %s(
post.id,
translation.title,
author.displayName,
post.publishedAt
)',
PostListItem::class,
))
->innerJoin('post.author', 'author')
->innerJoin(
'post.translations',
'translation',
'WITH',
'translation.locale = :locale',
)
->andWhere('post.published = :published')
->setParameter('published', true)
->setParameter('locale', $locale)
->orderBy('post.publishedAt', 'DESC')
->addOrderBy('post.id', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}Das Beispiel setzt einen ganzzahligen Wert für post.id, nicht-nullbare Titel und Autorennamen sowie ein datetime_immutable-Mapping für publishedAt voraus. Jeder ausgewählte Beitrag muss ein Veröffentlichungsdatum haben. Andere Mappings benötigen passende Konstruktortypen; das DTO wandelt nicht selbst eine beliebige Zeichenkette in ein Datumsobjekt um.
Die Sprachbedingung wählt die gewünschte Übersetzung aus. Eine Eindeutigkeitsbedingung in der Datenbank für das Paar Beitrag/Sprache muss höchstens einen passenden Übersetzungsdatensatz sicherstellen. Zusammen mit genau einem Autor ergibt sich dann ein DTO je passendem Beitrag, sodass das Limit Listenelemente begrenzt. Doppelte Übersetzungsdatensätze könnten sonst DTOs vervielfachen und die Zahl unterschiedlicher Beiträge pro Seite verringern.
Der innere Übersetzungs-Join schließt Beiträge ohne diese Sprache bewusst aus. Soll das Produkt eine Ersatzsprache oder einen Eintrag ohne Titel anzeigen, braucht es eine ausdrückliche Regel. Ein linker Join allein würde einen nullbaren Titelvertrag oder einen geeigneten Ersatzausdruck erfordern. Die Methode liefert die erste begrenzte Ergebnismenge; weitere Seiten benötigen Offset oder Cursor und dieselbe eindeutige Sortierung.
Diese Projektion baut DTOs aus ausgewählten Feldwerten auf, statt Entities zu hydrieren. Das Lesen ihrer Eigenschaften kann keine Beziehungen nachladen. Weniger Spalten und kein verwalteter Entity-Graph können Speicher- und Hydrierungsaufwand senken. Die Joins bleiben dennoch relevant für die Laufzeit. DTOs garantieren keine schnellere Abfrage und ersetzen keine Entities, die für fachliches Verhalten gebraucht werden.
Stapelweises Laden: zugehörige Daten für die ausgewählte Seite
Würden Collection-Joins das Ergebnis stark vergrößern, lässt sich zuerst eine Seite laden und danach die benötigte Ergänzung für deren IDs. Die projektspezifische Methode findPublishedPage() muss hier eine geordnete list<Post> liefern, deren Autoren bereits über einen To-one-Join geladen sind. $page und $limit sind validiert; getId() liefert für diese gespeicherten Beiträge einen Integer.
$posts = $postRepository->findPublishedPage($page, $limit);
$postIds = array_map(
static fn (Post $post): int => $post->getId(),
$posts,
);
$translations = $postIds === []
? []
: $translationRepository->findIndexedByPostIds(
postIds: $postIds,
locale: $locale,
);
$result = [];
foreach ($posts as $post) {
$result[] = [
'title' => $translations[$post->getId()] ?? null,
'author' => $post->getAuthor()->getDisplayName(),
];
}findIndexedByPostIds() ist eine beispielhafte Repository-Methode, keine Doctrine-API. Ihr Vertrag lautet hier array<int, string>: Beitrags-ID auf Titel in der gewünschten Sprache, geladen mit einer gemeinsamen Abfrage für die übergebenen IDs. Dabei gelten dieselben Zugriffsgrenzen und Eindeutigkeitsregeln für Übersetzungen. Die abschließende Schleife liest aus dieser Zuordnung, statt translationFor() aufzurufen. Ein fehlender Titel wird als null dargestellt.
Unter diesen Annahmen benötigt eine nicht leere Seite zwei Datenabfragen. Eine Gesamtzählung, weitere Beziehungen oder eine Aufteilung wegen Parametergrenzen können zusätzliche Abfragen erfordern. Würde die zweite Methode intern jede ID einzeln abfragen, wäre N+1 lediglich verschoben.
Ladeeinstellungen, vervielfachte Zeilen und Seitenauswahl
LAZY verschiebt das Laden einer Beziehung bis zum Bedarf. EAGER fordert früheres Laden an, bedeutet aber nicht „ein SQL-Join“. Abhängig von Mapping und Ladeweg kann Doctrine Joins oder zusätzliche Abfragen verwenden. Werden sämtliche Beziehungen eager geladen, bezahlen auch Konsolenbefehle und Ansichten für Daten, die sie gar nicht brauchen.
Bei Collections ermöglicht EXTRA_LAZY unterstützte Operationen wie count(), contains() oder slice(), ohne alle Elemente zu laden. Das gilt nicht für jede Collection-Methode. Iterieren lädt weiterhin die Collection; das Zählen von 50 ungeladenen Collections kann weiterhin 50 Abfragen ausführen. Welche Operationen unterstützt werden, hängt von der eingesetzten ORM-Version ab.
Mehrere unabhängige To-many-Joins bergen ein anderes Risiko. Für einen Beitrag mit 4 Übersetzungen, 20 Kommentaren und 8 Tags kann die Verknüpfung sämtlicher Kombinationen 4 × 20 × 8 = 640 SQL-Zeilen ergeben. Filter, Join-Typen und tatsächliche Kardinalitäten beeinflussen das Ergebnis. Es sind nicht 640 unterschiedliche Beiträge: Die normale Objekthydrierung führt Root-Entities anhand ihrer Identität zusammen. Datenbankarbeit, übertragene Zeilen und Hydrierung verursachen trotzdem Zeit- und Speicheraufwand.
DISTINCT entfernt gegebenenfalls identische ausgewählte SQL-Zeilen. Zeilen mit unterschiedlichen Übersetzungen, Kommentaren oder Tags sind nicht identisch. Der Aufwand ihrer Vervielfachung verschwindet dadurch also nicht.
Ein direktes Limit mit Offset begrenzt bei einem To-many-Fetch-Join SQL-Zeilen statt vollständiger Beiträge samt Collections. Die Seite kann dadurch weniger Beiträge oder unvollständige Collection-Daten enthalten. Verwende die zur installierten Doctrine-Version passende Paginierung mit geeigneter Einstellung für Collection-Joins und den erforderlichen Query Walkern zur Abfrageumformung. Auch zusätzliche Zähl- und ID-Abfragen gehören in die Messung.
Alternativ lassen sich zunächst etwa 25 eindeutige Beitrags-IDs in Seitenreihenfolge auswählen und anschließend deren Details laden. Filter und Zugriffsgrenzen müssen in beiden Phasen zusammenpassen. IN (:ids) erhält nicht die Reihenfolge der übergebenen IDs: Die Sortierung muss erneut angewendet oder das Ergebnis anhand der ID-Liste zusammengestellt werden. Sind zwischenzeitliche Datenänderungen relevant, müssen Transaktionsisolation oder die gewünschte Snapshot-Konsistenz festgelegt werden.
To-one-Joins vermeiden normalerweise die Collection-Vervielfachung; andere Joins derselben Abfrage können sie dennoch verursachen. Eine Sortierung nach einem To-many-Feld braucht zunächst eine eindeutige Regel, beispielsweise das Datum des neuesten Kommentars. Keyset-Paginierung kann bei tiefem, fortlaufendem Blättern helfen. Beliebige Sprünge zu Seitennummern oder eine Gesamtzahl liefert sie aber nicht ohne Weiteres. Entscheidend sind auch die Anforderungen an die Navigation.
Twig und Serialisierung gehören zum Leseweg
Ein Template kann fehlende Datenvorbereitung sichtbar machen, obwohl es selbst kein SQL enthält:
{% for post in posts %}
{{ post.author.displayName }}
{{ post.translationFor(app.request.locale).title }}
{% endfor %}Twig löst diese Werte über die Zugriffsmethoden des Objekts auf. Damit gelten dieselben Annahmen über geladene Beziehungen und vorhandene Übersetzungen wie in der PHP-Schleife. Vor dem Rendern sollten entweder gezielt geladene Entities oder ein passendes Ansichtsmodell bereitstehen. Ein DTO kann den lesbaren Datenumfang begrenzen, ist aber nicht für jedes Template erforderlich.
Bei JSON ist eine Unterscheidung wichtig:
json_encode($entity);json_encode() berücksichtigt standardmäßig öffentliche Eigenschaften. Es durchläuft nicht grundsätzlich alle privaten Doctrine-Beziehungen und ruft nicht automatisch deren Getter auf. Dagegen kann JsonSerializable::jsonSerialize() oder eigener Code zur Darstellung Beziehungen lesen. Symfony Serializer verwendet Normalizer: Ein Object Normalizer kann auf Getter oder Eigenschaften zugreifen, die Konfiguration und Serialisierungsgruppen freigeben. Maßgeblich sind der konkrete Normalizer und sein Kontext, nicht allein der Controller.
Eine Abfragemessung für eine HTTP-Antwort sollte daher auch Rendering oder Normalisierung abdecken. Ein reiner Repository-Test übersieht möglicherweise spätere Abfragen.
Regressionstests mit klarem Messfenster
Die Pest-Beispiele sind Skizzen für Integrationstests. createPublishedPosts(), createPostFixtures(), entityManager(), doctrineQueryCollector(), service() und repository() sind projektspezifische Hilfsmethoden. reset(), start(), stop() und queryCount() beschreiben den beispielhaften Collector-Vertrag, keine eingebauten Pest- oder Doctrine-Methoden. Der Collector muss ausgeführte Anweisungen auf der relevanten Verbindung zählen. Eine Middleware-basierte Messung muss vor dem Erstellen dieser Verbindung konfiguriert sein.
Die Testumgebung muss Datensätze isolieren, Caches kontrollieren und Services bereitstellen, ohne die Liste vorab zu laden. Nach dem Speichern der Testdaten wird derselbe EntityManager geleert, den der Service verwendet. So stammen die Ergebnisse nicht aus den bereits verwalteten Fixture-Objekten. clear() leert allerdings weder den Second-Level- noch den Anwendungscache.
Hier liefert getPublishedPosts() eine vollständig aufgebaute Liste, keinen verzögert ausgewerteten Iterator. Drei Abfragen sind ein beispielhaftes Budget für diesen Service:
it('begrenzt die Abfragezahl', function (int $count): void {
$this->createPublishedPosts(count: $count, locale: 'de');
$entityManager = $this->entityManager();
$service = $this->service();
$collector = $this->doctrineQueryCollector();
$entityManager->flush();
$entityManager->clear();
$collector->reset();
$collector->start();
try {
$result = $service->getPublishedPosts(
locale: 'de',
limit: $count,
);
} finally {
$collector->stop();
}
expect($result)->toHaveCount($count)
->and($collector->queryCount())->toBeLessThanOrEqual(3);
})->with([5, 50]);Der Datensatz führt den Test mit 5 und 50 Beiträgen aus. Unterschiedliche Autoren und mehrere Übersetzungen verhindern, dass die Wiederverwendung von Beziehungen die Regression verdeckt. Repräsentative Fälle mit gemeinsamen Autoren sollten ebenfalls geprüft werden. Verzögerte Ergebnisse, Templates oder Serializer müssen innerhalb des Messfensters verarbeitet werden. Ein Abfragebudget schützt vor wiederholten Datenbankaufrufen, misst aber weder Latenz noch Speicherbedarf.
Der folgende Repository-Test trennt den Aufbau des Ergebnisses von späteren Eigenschaftszugriffen:
it('liest DTOs ohne weiteres SQL', function (): void {
$this->createPostFixtures(count: 20, locale: 'de');
$entityManager = $this->entityManager();
$repository = $this->repository();
$collector = $this->doctrineQueryCollector();
$entityManager->flush();
$entityManager->clear();
$items = $repository->findPublishedList('de', 20);
expect($items)->toHaveCount(20)
->each->toBeInstanceOf(PostListItem::class);
$collector->reset();
$collector->start();
try {
foreach ($items as $item) {
expect($item->title)->toBeString()
->and($item->authorName)->toBeString();
}
} finally {
$collector->stop();
}
expect($collector->queryCount())->toBe(0);
});Er prüft Anzahl und Typ der DTOs und anschließend, dass das Lesen von Titel und Autorenname kein weiteres SQL ausführt. Den Aufwand von findPublishedList() selbst misst er nicht. Sprachwahl, fehlende Übersetzungen, Sortierung, Seitengrenzen und eindeutige IDs benötigen zusätzliche Assertions mit bekannten Testwerten. Eine Typprüfung allein belegt diese Eigenschaften nicht.
Typverträge und Prüfung KI-generierter Änderungen
PHPStan kann prüfen, ob Implementierungen und Aufrufe zum deklarierten Rückgabetyp passen. Diese Deklaration gehört in ein Interface:
/**
* @return list<PostListItem>
*/
public function findPublishedList(
string $locale,
int $limit,
): array;Präzise Collection-Elementtypen und ausdrücklich nullbare Verträge machen Fehler leichter erkennbar. PHPStan führt kein SQL aus und erkennt N+1 nicht direkt. Beliebige Architekturregeln benötigen gesonderte, projektspezifische Prüfungen. Statische Analyse und Laufzeitmessung beantworten unterschiedliche Fragen.
Bei Änderungen eines Entwicklers oder KI-Assistenten müssen Beziehungszugriffe in Schleifen mit dem tatsächlichen Mapping und der Repository-Abfrage abgeglichen werden. Ein solcher Zugriff ist nicht automatisch falsch, wenn die Daten bereits passend geladen sind. Im Review sollte erkennbar sein, welche Abfrage welchen benötigten Wert lädt, ob Mapper oder Serializer weitere Arbeit auslösen und ob Sortierung und Seitenauswahl stimmen. Geschätzte Abfragezahlen sind eine Hypothese; Tests und Messungen liefern die Belege. Eine lokale Optimierung rechtfertigt keine pauschale Änderung aller Lade-Mappings.
Auch nach der Auslieferung messen
In Produktion lassen sich Latenzperzentile eines Endpunkts mit Abfragezahl, gesamter Datenbankzeit, wiederholten SQL-Fingerprints, Seitengröße und Häufigkeit langsamer Abfragen zusammenführen. Soweit messbar, gehören untersuchte Zeilen, Hydrierung und Speicherbedarf ebenfalls dazu. Viele einzeln schnelle Anweisungen können zusammen teuer sein. Das Protokoll langsamer Abfragen allein erkennt deshalb nicht jedes N+1-Problem.
Wähle eine angemessene Stichprobe von Ablaufspuren und speichere sensible SQL-Parameter oder Kundendaten nicht unnötig. Auch normalisiertes SQL kann Literale oder identifizierende Angaben enthalten. Ein Fingerprint bedeutet keine automatische Anonymisierung.
Caching kann sinnvoll sein, sobald der Leseweg verstanden ist. Zu prüfen sind Verhalten bei leerem Cache, Invalidierung und Schlüssel, die den richtigen Benutzer oder die richtige Organisation berücksichtigen. Dieselbe Schleife in einen anderen Service zu verschieben ändert ihr SQL nicht. Global verändertes Lazy Loading kann bestehende Aufrufer beschädigen, ohne einen besseren Ladeplan zu schaffen.
Eine brauchbare Diagnose endet mit einem Vergleich: Lesezugriff reproduzieren, wiederholte Arbeit zuordnen, die kleinste geeignete Ladeänderung vornehmen und Inhalte, Reihenfolge, Sprache sowie Seitenauswahl erneut prüfen. Vergleiche Abfragezahl, Datenbankzeit und Speicher unter ähnlichen Bedingungen und beobachte die Auslieferung. Die passende Lösung erfüllt den konkreten Lesebedarf zu vertretbaren Gesamtkosten, nicht bloß mit weniger Anweisungen.
Technische Dokumentation
- Doctrine ORM: DQL, Fetch Joins und Konstruktorausdrücke — https://www.doctrine-project.org/projects/doctrine-orm/en/3.6/reference/dql-doctrine-query-language.html
- Doctrine ORM: Paginierung — https://www.doctrine-project.org/projects/doctrine-orm/en/3.6/tutorials/pagination.html
- Doctrine ORM: Extra-Lazy-Beziehungen — https://www.doctrine-project.org/projects/doctrine-orm/en/3.6/tutorials/extra-lazy-associations.html
- PHP: JSON-Kodierung — https://www.php.net/manual/en/function.json-encode.php
