ORM
SEARCH
Magento · PHP · SQL · Repository · Service Contracts
SearchCriteria vs. direkte SQL in Magento: Wann was

Magento SearchCriteria ist die saubere ORM-Schicht. Direktes SQL ist schneller und flexibler. Die Kunst liegt darin, zu wissen, wann welcher Ansatz die richtige Wahl ist — und was man riskiert, wenn man das ORM umgeht.

FilterGroup & Filter Repository::getList() getConnection()->query() Hybrid-Ansatz

1. Die Architekturentscheidung: ORM vs. SQL

Magento bietet zwei grundlegend verschiedene Wege, auf Datenbankdaten zuzugreifen: die SearchCriteria-API mit Repositories und Service Contracts auf der einen Seite, und direktes SQL über die Datenbankverbindung auf der anderen. Beide Ansätze sind legitim — aber für unterschiedliche Szenarien geeignet.

Die Entscheidung ist keine Frage von "richtig" oder "falsch", sondern von Kontext. SearchCriteria respektiert Plugins, Events und Multi-Store-Logik, ist testbar und API-stabil. Direktes SQL ist schneller, flexibler bei komplexen Joins und unverzichtbar für Bulk-Operationen auf Millionen von Zeilen. Wer den Unterschied nicht kennt, benutzt entweder SearchCriteria dort, wo die Performance einbricht, oder umgeht das ORM dort, wo das Sicherheitsrisiko zu hoch ist.

2. Die SearchCriteria API

Die SearchCriteria API in Magento besteht aus mehreren Bausteinen. Ein Filter ist die atomare Einheit: Feldname, Wert und Kondition (eq, like, gt, in, etc.). FilterGroups fassen mehrere Filter mit OR zusammen. Mehrere FilterGroups in einem SearchCriteria werden mit AND verknüpft. SortOrder definiert die Sortierung, PageSize und CurrentPage steuern die Paginierung.

// PHP: Building a SearchCriteria with FilterGroup and SortOrder
use Magento\Framework\Api\SearchCriteriaBuilder;
use Magento\Framework\Api\FilterBuilder;
use Magento\Framework\Api\SortOrderBuilder;
use Magento\Sales\Api\OrderRepositoryInterface;

// Filter 1: status IN ('complete', 'processing')
$statusFilter = $this->filterBuilder
    ->setField('status')
    ->setValue(['complete', 'processing'])
    ->setConditionType('in')
    ->create();

// Filter 2: created_at >= 2025-01-01
$dateFilter = $this->filterBuilder
    ->setField('created_at')
    ->setValue('2025-01-01 00:00:00')
    ->setConditionType('gteq')
    ->create();

// SortOrder: newest first
$sortOrder = $this->sortOrderBuilder
    ->setField('created_at')
    ->setDescendingDirection()
    ->create();

$searchCriteria = $this->searchCriteriaBuilder
    ->addFilters([$statusFilter])  // each addFilters() = one FilterGroup (AND between groups)
    ->addFilters([$dateFilter])
    ->addSortOrder($sortOrder)
    ->setPageSize(50)
    ->setCurrentPage(1)
    ->create();

$searchResults = $this->orderRepository->getList($searchCriteria);
$orders = $searchResults->getItems(); // array of OrderInterface

Das Ergebnis von getList() ist ein SearchResultsInterface-Objekt, das neben den Items auch die Gesamtanzahl (getTotalCount()) enthält — nützlich für Paginierungslogik, ohne eine separate Count-Query abzusetzen.

3. Repository::getList() korrekt nutzen

Ein häufiger Fehler ist, alle Datensätze auf einmal zu laden, indem auf setPageSize() verzichtet wird. Das führt bei großen Tabellen zu massivem Memory-Verbrauch. Eine weitere Falle ist die Verwechslung von OR und AND: Innerhalb einer addFilters([])-Gruppe werden alle Filter mit OR verknüpft. Zwischen verschiedenen addFilters()-Aufrufen gilt AND.

// Correct: OR within a FilterGroup (same addFilters() call)
// Finds orders with status = 'complete' OR status = 'processing'
$this->searchCriteriaBuilder
    ->addFilters([
        $this->filterBuilder->setField('status')->setValue('complete')->setConditionType('eq')->create(),
        $this->filterBuilder->setField('status')->setValue('processing')->setConditionType('eq')->create(),
    ]);

// Correct: AND between FilterGroups (separate addFilters() calls)
// Finds orders created today AND with customer_id = 100
$this->searchCriteriaBuilder
    ->addFilters([
        $this->filterBuilder->setField('created_at')->setValue('2025-01-15')->setConditionType('gteq')->create(),
    ])
    ->addFilters([
        $this->filterBuilder->setField('customer_id')->setValue(100)->setConditionType('eq')->create(),
    ]);

4. Wann SearchCriteria die richtige Wahl ist

SearchCriteria ist die richtige Wahl in folgenden Szenarien:

  • API-exponierte Endpunkte: Jeder REST- oder GraphQL-Endpunkt, der Magento-Entities zurückgibt, soll über Service Contracts gehen, damit Extension Attributes automatisch mitgeliefert werden.
  • Plugin-Unterstützung erwartet: Wenn andere Module per Plugin auf Repository-Aufrufe reagieren sollen (z.B. für Multi-Store-Filterung oder Kundenspezifika), muss der Weg über das Repository gehen.
  • Testbarkeit: Repository-Interfaces sind einfach zu mocken. Direktes SQL in einem Service ist schwer zu unit-testen.
  • Multi-Store-Kontext: Einige Repositories filtern automatisch nach Store-Scope. Direktes SQL muss das explizit selbst implementieren.

5. Direktes SQL mit getConnection()

Magento stellt über ResourceConnection und AdapterInterface Zugriff auf die rohe Datenbankverbindung bereit. Für einfache Queries reicht fetchAll(), für komplexe Abfragen steht der Zend_Db_Select-Builder zur Verfügung.

// PHP: Direct SQL via ResourceConnection
use Magento\Framework\App\ResourceConnection;

// Simple query with fetchAll
$connection = $this->resourceConnection->getConnection();
$tableName  = $connection->getTableName('sales_order');

$select = $connection->select()
    ->from(['o' => $tableName], ['entity_id', 'increment_id', 'grand_total'])
    ->where('o.status IN (?)', ['complete', 'processing'])
    ->where('o.created_at >= ?', '2025-01-01 00:00:00')
    ->order('o.created_at DESC')
    ->limit(100);

$orders = $connection->fetchAll($select); // array of assoc arrays

// Complex cross-entity report (not possible via SearchCriteria)
$itemTable  = $connection->getTableName('sales_order_item');
$select = $connection->select()
    ->from(['oi' => $itemTable], [
        'sku',
        'product_name' => 'oi.name',
        'qty_sold'     => new \Zend_Db_Expr('SUM(oi.qty_ordered)'),
        'revenue'      => new \Zend_Db_Expr('SUM(oi.row_total)'),
    ])
    ->join(['o' => $tableName], 'o.entity_id = oi.order_id', [])
    ->where('o.status IN (?)', ['complete', 'processing'])
    ->where('oi.parent_item_id IS NULL')
    ->group('oi.sku')
    ->order('revenue DESC')
    ->limit(10);

$topProducts = $connection->fetchAll($select);

6. Wann direktes SQL besser ist

Direktes SQL ist die bessere Wahl in folgenden Situationen:

  • Bulk-Operationen: Ein UPDATE sales_order SET status = 'complete' WHERE ... mit Zehntausenden von Zeilen ist über direktes SQL um Größenordnungen schneller als eine Schleife mit $repository->save().
  • Komplexe Cross-Entity-Reports: JOINs über mehrere Entitäten (order + item + customer + creditmemo) lassen sich über SearchCriteria nicht ausdrücken, weil jedes Repository nur seine eigene Entität kennt.
  • Performance-kritische Read-Paths: Hochfrequente Lesezugriffe, bei denen Object-Hydration und Plugin-Overhead messbar ins Gewicht fallen.
  • Statistik- und Aggregationsabfragen: GROUP BY, HAVING, Window Functions und Aggregationen sind mit SearchCriteria schlicht nicht abbildbar.

7. Risiken beim ORM-Bypass

Wer direktes SQL nutzt, muss die Konsequenzen kennen und bewusst akzeptieren:

Keine Plugin-/Event-Ausführung: Ein direktes UPDATE oder INSERT auf eine Magento-Tabelle löst keine before_save-, after_save-Events und keine Plugins aus. Module, die auf diese Events reagieren (z.B. Indexer, Cache-Invalidierung, ERP-Connectoren), werden nicht benachrichtigt.

Kein automatisches Cache-Clearing: Magento's ORM-Layer räumt nach einem repository->save() automatisch relevante Cache-Tags auf. Direktes SQL macht das nicht — manuelles Cache-Flushen ist notwendig.

Schema-Fragilität: Wenn eine Extension eine Tabelle umbenennt oder Spalten ändert, bricht direktes SQL ohne Kompilierfehler. Das ORM abstrahiert über Tabellennamen ($connection->getTableName() sollte immer genutzt werden).

// Always use getTableName() to avoid hardcoded table names
// Wrong: hardcoded table name breaks with table prefix
$connection->query("UPDATE sales_order SET status = 'complete' WHERE ...");

// Correct: resolve table name via ResourceConnection
$tableName = $this->resourceConnection->getTableName('sales_order');
$connection->update(
    $tableName,
    ['status' => 'complete'],
    ['entity_id IN (?)' => [1, 2, 3]]
);

8. Hybrid-Ansatz: SQL für Reads, Repository für Writes

Der pragmatischste Ansatz für viele Magento-Projekte ist ein Hybrid: Direktes SQL für performante Lesezugriffe und komplexe Reporting-Queries, aber immer Repository für Schreiboperationen, die Events und Plugins brauchen. Dieser Ansatz kombiniert Performance mit Korrektheit.

// Hybrid example: direct SQL for fast read, Repository for write
// READ: find IDs of orders to update (fast, direct SQL)
$connection = $this->resourceConnection->getConnection();
$tableName  = $connection->getTableName('sales_order');

$select = $connection->select()
    ->from($tableName, ['entity_id'])
    ->where('status = ?', 'processing')
    ->where('created_at < ?', date('Y-m-d H:i:s', strtotime('-7 days')));

$orderIds = $connection->fetchCol($select); // [101, 205, 318, ...]

// WRITE: update via Repository to fire events and plugins
foreach ($orderIds as $orderId) {
    $order = $this->orderRepository->get($orderId);
    $order->setStatus('holded');
    $this->orderRepository->save($order);
    // This fires: sales_order_save_before, sales_order_save_after, etc.
}

Für wirkliche Bulk-Updates — tausende oder zehntausende Zeilen — ist selbst dieser Ansatz zu langsam, weil repository->save() pro Zeile viele Datenbankoperationen auslöst. In solchen Fällen muss direktes SQL für den Write akzeptiert werden, mit anschließend manueller Cache-Invalidierung und Indexer-Ausführung.

Mironsoft

Magento-Datenbankzugriffe sauber und performant architektieren

Wir helfen dabei, die richtige Balance zwischen SearchCriteria und direktem SQL in Magento zu finden — für wartbare Architektur und echte Performance-Gewinne.

Code Review

Repository-Pattern, SearchCriteria-Nutzung und direktes SQL auf Korrektheit prüfen

Performance-Optimierung

Slow Queries identifizieren und ORM-Bottlenecks durch gezieltes direktes SQL ersetzen

Architektur-Beratung

Bulk-Import, Reporting-Layer und API-Integration sauber in Magento integrieren

Magento SearchCriteria vs. direktes SQL — Das Wichtigste auf einen Blick

SearchCriteria wählen wenn

API-Exposition, Plugin-Support, Testbarkeit und Multi-Store-Logik wichtig sind.

Direktes SQL wählen wenn

Bulk-Operationen, komplexe JOINs, Aggregationen oder Performance-kritische Read-Paths nötig sind.

ORM-Bypass Risiko

Keine Events/Plugins, kein automatisches Cache-Clearing, Schema-Fragilität bei Tabellenänderungen.

Hybrid-Ansatz

Direktes SQL für schnelle Reads und Reporting, Repository immer für Writes die Events erfordern.

10. Zusammenfassung

Magento SearchCriteria ist die korrekte ORM-Schicht für API-exponierte Datenzugriffe, testbare Services und alles, was Plugins und Events nutzen soll. Direktes SQL über getConnection() ist die pragmatische Wahl für Bulk-Operationen, komplexe Cross-Entity-Reports und performance-kritische Lesepfade. Die wichtigsten Risiken beim ORM-Bypass sind fehlende Event-/Plugin-Ausführung und kein automatisches Cache-Clearing.

Der Hybrid-Ansatz — direktes SQL für Reads, Repository für Writes — ist in vielen Projekten der beste Kompromiss. Entscheidend ist, die Wahl bewusst zu treffen und die Konsequenzen zu kennen: Wer direktes SQL für Writes einsetzt, muss Cache-Tags manuell invalidieren und Indexer gezielt anstoßen.

11. FAQ: Magento SearchCriteria vs. direktes SQL

1Was ist der Unterschied zwischen SearchCriteria und direktem SQL in Magento?
SearchCriteria nutzt Repository- und Service-Contract-Layer, respektiert Plugins und Events. Direktes SQL geht daran vorbei — schneller, aber ohne Plugin-Support und automatisches Cache-Clearing.
2Wie verknüpfe ich Filter mit OR in SearchCriteria?
Mehrere Filter in einem addFilters([...])-Aufruf werden mit OR verknüpft. Separate addFilters()-Aufrufe gelten als AND.
3Warum gibt getList() SearchResultsInterface zurück?
SearchResultsInterface enthält neben Items auch getTotalCount() für die Gesamtanzahl — nötig für korrekte Paginierung ohne separate Count-Query.
4Welche Risiken entstehen beim direkten SQL in Magento?
Keine Plugin-/Event-Ausführung, kein automatisches Cache-Clearing, Schema-Fragilität. Immer getTableName() nutzen.
5Wann ist direktes SQL unumgänglich?
Bulk-Operationen, komplexe Cross-Entity-JOINs, Aggregationsabfragen und performance-kritische Read-Paths — all das kann SearchCriteria nicht leisten.
6Was ist der Hybrid-Ansatz?
Direktes SQL für schnelle Lesezugriffe und Reports. Repository für alle Schreiboperationen, die Events und Plugins brauchen.
7Warum immer getTableName() nutzen?
Magento-Installationen können ein Tabellenpräfix haben. getTableName() löst den logischen Namen korrekt auf. Hardcodierte Namen brechen bei Präfix-Setups.
8Kann SearchCriteria für Reporting-Queries genutzt werden?
Nur für einfache Filterung einer Entität. GROUP BY, HAVING, Window Functions und Cross-Entity-Joins sind nicht abbildbar.
9Wie teste ich Services mit direktem SQL?
Eine dedizierte Query-Klasse erstellen, die SQL kapselt, und diese im Unit-Test mocken. Direktes SQL im Service selbst ist kaum unit-testbar.
10Was passiert mit Magento-Indizes beim direkten SQL-Update?
Indizes werden nicht automatisch aktualisiert. Nach Bulk-Writes müssen betroffene Indexer manuell über bin/magento indexer:reindex angestoßen werden.